diff --git a/.github/scripts/github_release.py b/.github/scripts/github_release.py index 289ac2b..21dd814 100644 --- a/.github/scripts/github_release.py +++ b/.github/scripts/github_release.py @@ -19,14 +19,20 @@ import hashlib import json import os +import shutil import subprocess import sys -from collections.abc import Sequence from dataclasses import dataclass, field from pathlib import Path -from typing import Protocol, TypedDict +from typing import TYPE_CHECKING, Protocol, TypeVar + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence DIGEST_PREFIX = "sha256:" +UPLOADED_STATE = "uploaded" + +_FieldType = TypeVar("_FieldType") class ReleaseError(Exception): @@ -47,6 +53,21 @@ class Release: assets: dict[str, str] = field(default_factory=dict) +@dataclass(frozen=True) +class Publication: + """What one run publishes: release ``tag`` of ``commit`` holding ``files``. + + ``title`` and ``notes`` are used only when the run creates the release; + an existing release keeps its own. + """ + + tag: str + commit: str + files: tuple[Path, ...] + title: str + notes: str + + class GitHub(Protocol): """The GitHub operations a release needs, addressed by tag name.""" @@ -76,48 +97,47 @@ def file_digest(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() -def publish_release( - github: GitHub, - *, - tag: str, - commit: str, - files: Sequence[Path], - title: str, - notes: str, -) -> None: - """Make ``tag`` a published release of ``commit`` that holds ``files``. +def publish_release(github: GitHub, publication: Publication) -> None: + """Make ``publication.tag`` a published release of its commit and files. Safe to repeat: each step checks GitHub first and only does what is missing, so a run that stopped after any step is completed by the next one. Raises ReleaseError when the tag or a draft belongs to another commit, or when GitHub does not hold the expected state at the end. """ + tag, commit = publication.tag, publication.commit _check_tag(github, tag, commit) release = github.release(tag) if release is None: - github.create_draft(tag, commit, title, notes) + github.create_draft(tag, commit, publication.title, publication.notes) release = Release(draft=True, target=commit) elif release.draft and release.target != commit: msg = f"Draft release {tag} targets {release.target}, not {commit}" raise ReleaseError(msg) - missing = [f for f in files if release.assets.get(f.name) != file_digest(f)] + missing = _differing_files(release, publication.files) if missing: github.upload(tag, missing) if release.draft: github.publish(tag) - _verify(github, tag, commit, files) + _verify(github, publication) + + +def _differing_files(release: Release, files: Sequence[Path]) -> list[Path]: + """Return the ``files`` that ``release`` lacks or holds with other bytes.""" + return [f for f in files if release.assets.get(f.name) != file_digest(f)] def _check_tag(github: GitHub, tag: str, commit: str) -> None: tag_commit = github.tag_commit(tag) - if tag_commit not in (None, commit): + if tag_commit not in {None, commit}: msg = f"Tag {tag} points at {tag_commit}, not {commit}" raise ReleaseError(msg) -def _verify(github: GitHub, tag: str, commit: str, files: Sequence[Path]) -> None: +def _verify(github: GitHub, publication: Publication) -> None: + tag, commit = publication.tag, publication.commit release = github.release(tag) if release is None or release.draft: msg = f"Release {tag} is not published" @@ -125,38 +145,88 @@ def _verify(github: GitHub, tag: str, commit: str, files: Sequence[Path]) -> Non if github.tag_commit(tag) != commit: msg = f"Tag {tag} does not point at {commit}" raise ReleaseError(msg) - wrong = [f.name for f in files if release.assets.get(f.name) != file_digest(f)] + wrong = [f.name for f in _differing_files(release, publication.files)] if wrong: msg = f"Release {tag} assets differ from the built files: {', '.join(wrong)}" raise ReleaseError(msg) -class _AssetJson(TypedDict): - name: str - state: str - digest: str | None +def parse_release(text: str) -> Release: + """Read ``gh release view --json isDraft,targetCommitish,assets`` output. + + Only assets in the ``uploaded`` state with a ``sha256:`` digest are kept. + Raises ReleaseError when the JSON does not have the expected shape. + """ + data = _json_object(json.loads(text)) + assets: dict[str, str] = {} + for item in _field(data, "assets", list): + asset = _json_object(item) + digest = asset.get("digest") + if ( + _field(asset, "state", str) == UPLOADED_STATE + and isinstance(digest, str) + and digest.startswith(DIGEST_PREFIX) + ): + assets[_field(asset, "name", str)] = digest.removeprefix(DIGEST_PREFIX) + return Release( + draft=_field(data, "isDraft", bool), + target=_field(data, "targetCommitish", str), + assets=assets, + ) + + +def has_tag_ref(text: str, tag: str) -> bool: + """Tell whether ``git/matching-refs/tags/`` output holds exactly ``tag``. + + The endpoint matches by prefix, so ``v1.2`` also lists ``v1.2.3``; only an + exact ``refs/tags/`` counts. + """ + ref = f"refs/tags/{tag}" + refs = _json_list(json.loads(text)) + return any(_field(_json_object(item), "ref", str) == ref for item in refs) + + +def _json_object(value: object) -> Mapping[str, object]: + if not isinstance(value, dict): + msg = f"Expected a JSON object from gh, got {type(value).__name__}" + raise ReleaseError(msg) + # JSON object keys are always strings; the check narrows the key type. + return {key: item for key, item in value.items() if isinstance(key, str)} -class _ReleaseJson(TypedDict): - isDraft: bool - targetCommitish: str - assets: list[_AssetJson] +def _json_list(value: object) -> list[object]: + if not isinstance(value, list): + msg = f"Expected a JSON array from gh, got {type(value).__name__}" + raise ReleaseError(msg) + return list(value) -class _RefJson(TypedDict): - ref: str +def _field(data: Mapping[str, object], key: str, kind: type[_FieldType]) -> _FieldType: + value = data.get(key) + if not isinstance(value, kind): + msg = f"Expected {kind.__name__} {key!r} in gh output, got {value!r}" + raise ReleaseError(msg) + return value class GhCli: """GitHub operations through the ``gh`` CLI, authenticated by GH_TOKEN.""" def __init__(self, repo: str) -> None: - """Address the repository ``repo`` (``owner/name``).""" + """Address the repository ``repo`` (``owner/name``) through ``gh`` on PATH. + + Raises ReleaseError when ``gh`` is not installed. + """ + executable = shutil.which("gh") + if executable is None: + msg = "The gh CLI is not on PATH" + raise ReleaseError(msg) self.repo = repo + self._executable = executable def release(self, tag: str) -> Release | None: """Return the release for ``tag``; ``gh release view`` also finds drafts.""" - result = _gh( + result = self._run( "release", "view", tag, "--repo", self.repo, "--json", "isDraft,targetCommitish,assets", check=False, @@ -165,33 +235,22 @@ def release(self, tag: str) -> Release | None: if "release not found" in result.stderr: return None raise ReleaseError(result.stderr.strip()) - data: _ReleaseJson = json.loads(result.stdout) - assets = { - asset["name"]: digest.removeprefix(DIGEST_PREFIX) - for asset in data["assets"] - if asset["state"] == "uploaded" - and (digest := asset["digest"] or "").startswith(DIGEST_PREFIX) - } - return Release( - draft=data["isDraft"], target=data["targetCommitish"], assets=assets - ) + return parse_release(result.stdout) def tag_commit(self, tag: str) -> str | None: """Return the commit of ``refs/tags/``, peeling annotated tags.""" - ref = f"refs/tags/{tag}" # matching-refs answers an absent tag with an empty list instead of an error. - refs: list[_RefJson] = json.loads( - _gh("api", f"repos/{self.repo}/git/matching-refs/tags/{tag}").stdout - ) - if all(r["ref"] != ref for r in refs): + refs = self._run("api", f"repos/{self.repo}/git/matching-refs/tags/{tag}") + if not has_tag_ref(refs.stdout, tag): return None - return _gh( - "api", f"repos/{self.repo}/commits/{ref}", "--jq", ".sha" - ).stdout.strip() + commit = self._run( + "api", f"repos/{self.repo}/commits/refs/tags/{tag}", "--jq", ".sha" + ) + return commit.stdout.strip() def create_draft(self, tag: str, target: str, title: str, notes: str) -> None: """Create the draft; ``--notes`` is prepended to the generated notes.""" - _gh( + self._run( "release", "create", tag, "--repo", self.repo, "--draft", "--target", target, "--title", title, "--generate-notes", "--notes", notes, @@ -199,48 +258,58 @@ def create_draft(self, tag: str, target: str, title: str, notes: str) -> None: def upload(self, tag: str, files: Sequence[Path]) -> None: """Upload ``files``; ``--clobber`` replaces partial or stale assets.""" - _gh( - "release", "upload", tag, *map(str, files), "--repo", self.repo, "--clobber" - ) + paths = [str(path) for path in files] + self._run("release", "upload", tag, *paths, "--repo", self.repo, "--clobber") def publish(self, tag: str) -> None: """Publish the draft, which creates the tag at its target commit.""" - _gh("release", "edit", tag, "--repo", self.repo, "--draft=false") + self._run("release", "edit", tag, "--repo", self.repo, "--draft=false") + def _run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + """Run ``gh`` with ``args``; raise ReleaseError on failure when ``check``.""" + result = subprocess.run( # noqa: S603 -- fixed gh subcommands; values come from the workflow + [self._executable, *args], capture_output=True, text=True, check=False + ) + if check and result.returncode != 0: + msg = f"gh {args[0]} {args[1]} failed: {result.stderr.strip()}" + raise ReleaseError(msg) + return result -def _gh(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: - result = subprocess.run( # noqa: S603 -- fixed gh subcommands; values come from the workflow - ["gh", *args], capture_output=True, text=True, check=False - ) - if check and result.returncode != 0: - msg = f"gh {args[0]} {args[1]} failed: {result.stderr.strip()}" - raise ReleaseError(msg) - return result +def publication_from_environment(dist_dir: Path) -> Publication: + """Describe the release of every file in ``dist_dir`` from NAME and VERSION. -def main(argv: Sequence[str]) -> int: - """Publish the files in ``argv[1]`` as the release of the environment's version.""" + Reads RELEASE_SHA for the commit; raises KeyError when a variable is unset. + """ name, version = os.environ["NAME"], os.environ["VERSION"] tag = f"v{version}" - files = sorted(p for p in Path(argv[1]).iterdir() if p.is_file()) notes = ( f"Install with `pipx install {name}=={version}` or " f"`uv tool install {name}=={version}`; " - "upgrade an existing install with `kilm update`." + "upgrade an existing install with `kilm update perform`." + ) + return Publication( + tag=tag, + commit=os.environ["RELEASE_SHA"], + files=tuple(sorted(p for p in dist_dir.iterdir() if p.is_file())), + title=f"Release {tag}", + notes=notes, ) + + +def main(argv: Sequence[str]) -> int: + """Publish the files in ``argv[1]`` as the release of the environment's version. + + Returns the exit status; a ReleaseError becomes a workflow error annotation. + """ + publication = publication_from_environment(Path(argv[1])) try: - publish_release( - GhCli(os.environ["GITHUB_REPOSITORY"]), - tag=tag, - commit=os.environ["RELEASE_SHA"], - files=files, - title=f"Release {tag}", - notes=notes, - ) + publish_release(GhCli(os.environ["GITHUB_REPOSITORY"]), publication) except ReleaseError as error: - print(f"::error::{error}", file=sys.stderr) + sys.stderr.write(f"::error::{error}\n") return 1 - print(f"Release {tag} is published with {len(files)} files.") + count = len(publication.files) + sys.stdout.write(f"Release {publication.tag} is published with {count} files.\n") return 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff6646a..0cbe3b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/AGENTS.md b/AGENTS.md index 1145ff5..443ce5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Windows. | --- | --- | | `just` | List the commands. | | `just setup` | Install the locked Python and docs dependencies and the git hooks. Safe to rerun, including in a new worktree. | -| `just check` | Everything CI requires: `ruff format --check`, `ruff check`, `ty check`, and the tests. Never modifies files. | +| `just check` | Everything CI requires: `ruff format --check`, `ruff check`, `ty check`, and the tests (which include the module-size and suppression gates). Never modifies files. | | `just test [args]` | Fast test run without coverage; arguments go to pytest. | | `just cov` | Tests with coverage; fails below `fail_under` in `pyproject.toml`. | | `just fix` | Format and apply safe lint fixes. | @@ -38,12 +38,16 @@ files; tests run in `just check`. - `kicad_lib_manager/commands//`: one package per command, with `command.py` and a collocated `docs.mdx`. `docs/scripts/sync-embedded-docs.ts` copies each `docs.mdx` into the site's CLI reference, so update it in the same - change as the command. -- `kicad_lib_manager/services/` and `interfaces/`: configuration, KiCad, - library, and self-update services and their protocols. -- `kicad_lib_manager/utils/`: file operations, backups, metadata, templates, - environment variables, git helpers. -- `tests/`: pytest suite. + change as the command. Larger commands split into further modules beside + `command.py` (for example `setup/`, `template/`, `update/`, `import_zip/`). +- `kicad_lib_manager/commands/shared.py`: option helpers shared by several + commands. +- `kicad_lib_manager/services/`: configuration, KiCad, library, and + self-update services. +- `kicad_lib_manager/utils/`: file operations, backups, metadata, the + `template/` package, environment variables, git helpers, `console.py`, and + `kicad_common.py` (the one typed `kicad_common.json` access layer). +- `tests/`: pytest suite; `tests/fault_injection.py` simulates failed writes. - `docs/`: Astro Starlight site; guides in `docs/src/content/docs/`. - `flake.nix`, `pkgs/kilm.nix`: Nix package, built and tested by CI. It reads name, version, and dependencies from `pyproject.toml`, so a new runtime @@ -54,14 +58,27 @@ files; tests run in `just check`. ## Standards -- `pyproject.toml` holds the ruff rules and the ty configuration. Never weaken - a rule, threshold, or type to make a check pass; fix the code. +- `pyproject.toml` holds the ruff rules (`select = ["ALL"]` with complexity + limits) and the ty configuration (every rule at error). Never weaken a rule, + threshold, or type to make a check pass; fix the code. +- Modules stay within 600 lines; `tests/test_repository_limits.py` enforces it + because no lint rule does. Split a module rather than raising the limit. - Every suppression names the rule and states why, for example `# noqa: S603 -- arguments are a fixed git command` or `# ty: ignore[invalid-assignment] -- `. Blanket `# noqa` and - `# type: ignore` are not allowed. + `# type: ignore` are not allowed; `tests/test_repository_limits.py` rejects + suppressions without a rule and reason. - Full type hints. Do not add `Any`; use precise types, `TypedDict`, protocols, or generics. +- A module whose heavy third-party import (questionary, requests, jinja2, + yaml, ...) is used only inside functions lists it in `__lazy_modules__` + before its imports, so Python 3.15+ loads it on first use (PEP 810). Keep + imports at module top level; the `lazy import` syntax needs Python 3.15. +- Print through `utils.console.get_console()`, which builds a console per call; + a Rich console created at import time fixes the terminal mode too early. +- Declare Typer options with `Annotated[..., typer.Option(...)]`. Typer reads + command annotations at runtime, so keep their imports out of + `TYPE_CHECKING` blocks. - Use `pathlib.Path` for paths and keep behavior identical on Windows, macOS, and Linux. - No emojis in code, comments, or CLI output. Put fixed values in diff --git a/docs/src/content/docs/community/development.mdx b/docs/src/content/docs/community/development.mdx index a18595d..8c48435 100644 --- a/docs/src/content/docs/community/development.mdx +++ b/docs/src/content/docs/community/development.mdx @@ -49,7 +49,7 @@ Run the CLI from the checkout with `uv run kilm --help`. | Command | Purpose | | --- | --- | | `just` | List the commands. | -| `just check` | Formatting check, lint (ruff), type check (ty), and tests. Never modifies files. | +| `just check` | Formatting check, lint (ruff), type check (ty), and tests, including the 600-line module limit. Never modifies files. | | `just test [args]` | Fast test run; arguments go to pytest, for example `just test -k sync`. | | `just cov` | Tests with coverage and the minimum threshold. | | `just fix` | Format the code and apply safe lint fixes. | diff --git a/docs/src/content/docs/guides/configuration.mdx b/docs/src/content/docs/guides/configuration.mdx index 111024d..71ace79 100644 --- a/docs/src/content/docs/guides/configuration.mdx +++ b/docs/src/content/docs/guides/configuration.mdx @@ -74,7 +74,7 @@ version: "1.0.0" This file is created by `kilm init` and used to share library information across teams. -- **Backup files**: `*.backup.YYYYMMDD_HHMMSS` format +- **Backup files**: `*.backup.YYYYMMDD_HHMMSS_ffffff` format (local time, with microseconds) - **Location**: Same directory as original KiCad config files - **Retention**: Controlled by `max_backups` setting - **Files backed up**: @@ -94,8 +94,8 @@ Common configuration management commands: # View current configuration kilm status -# Set default library -kilm config set-default /path/to/library +# Set the default library by its registered name +kilm config set-default my-library # View configuration file location kilm config --help diff --git a/kicad_lib_manager/__init__.py b/kicad_lib_manager/__init__.py index 0198006..a2d4af4 100644 --- a/kicad_lib_manager/__init__.py +++ b/kicad_lib_manager/__init__.py @@ -1,3 +1 @@ -""" -KiCad Library Manager - Library management utilities for KiCad -""" +"""KiCad Library Manager: manage KiCad libraries across projects and machines.""" diff --git a/kicad_lib_manager/__main__.py b/kicad_lib_manager/__main__.py index 840bbfa..ae3c35b 100644 --- a/kicad_lib_manager/__main__.py +++ b/kicad_lib_manager/__main__.py @@ -1,8 +1,6 @@ -""" -Entry point for running the package as a module -""" +"""Run the ``kilm`` command line with ``python -m kicad_lib_manager``.""" -from .main import app +from kicad_lib_manager.main import app if __name__ == "__main__": app() diff --git a/kicad_lib_manager/commands/__init__.py b/kicad_lib_manager/commands/__init__.py index 43fc5ff..67a8358 100644 --- a/kicad_lib_manager/commands/__init__.py +++ b/kicad_lib_manager/commands/__init__.py @@ -1,3 +1 @@ -""" -Command implementations for KiCad Library Manager CLI. -""" +"""Command implementations for KiCad Library Manager CLI.""" diff --git a/kicad_lib_manager/commands/add_3d/__init__.py b/kicad_lib_manager/commands/add_3d/__init__.py index 8676021..5f6e008 100644 --- a/kicad_lib_manager/commands/add_3d/__init__.py +++ b/kicad_lib_manager/commands/add_3d/__init__.py @@ -1,3 +1,5 @@ -from .command import add_3d +"""The ``add-3d`` command: register 3D model libraries.""" + +from kicad_lib_manager.commands.add_3d.command import add_3d __all__ = ["add_3d"] diff --git a/kicad_lib_manager/commands/add_3d/command.py b/kicad_lib_manager/commands/add_3d/command.py index bb8242e..2fd62ff 100644 --- a/kicad_lib_manager/commands/add_3d/command.py +++ b/kicad_lib_manager/commands/add_3d/command.py @@ -1,26 +1,184 @@ -""" -Add cloud-based 3D models directory command for KiCad Library Manager (Typer version). +"""The ``add-3d`` command: register a directory of 3D models with KiLM. + +The directory's settings live in a cloud metadata file inside it, so the same +name and environment variable apply on every machine that syncs the folder. """ +from dataclasses import dataclass from pathlib import Path from typing import Annotated import typer -from rich.console import Console -from ...services.config_service import Config -from ...utils.metadata import ( +from kicad_lib_manager.commands.shared import given +from kicad_lib_manager.services.config_service import Config +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.metadata import ( CLOUD_METADATA_FILE, + CloudMetadata, + CloudMetadataFile, + count_model_files, generate_env_var_name, get_default_cloud_metadata, read_cloud_metadata, + read_cloud_metadata_file, write_cloud_metadata, ) -console = Console() + +@dataclass(frozen=True) +class _Overrides: + """Metadata values given on the command line; None means not given.""" + + name: str | None + description: str | None + env_var: str | None + no_env_var: bool + + def given(self) -> bool: + """Return whether any value was given.""" + return ( + self.name is not None + or self.description is not None + or self.env_var is not None + or self.no_env_var + ) + + +def _present(value: str | int | None) -> bool: + """Return whether a metadata value is set to something non-empty.""" + return value is not None and bool(value) + + +def _apply_overrides(metadata: CloudMetadata, overrides: _Overrides) -> None: + """Set the values given on the command line in ``metadata``, announcing each.""" + console = get_console() + if overrides.name is not None: + metadata["name"] = overrides.name + console.print(f"Overriding with provided name: [blue]{overrides.name}[/blue]") + if overrides.description is not None: + metadata["description"] = overrides.description + console.print( + "Overriding with provided description: " + f"[blue]{overrides.description}[/blue]" + ) + if overrides.env_var is not None: + metadata["env_var"] = overrides.env_var + console.print( + "Overriding with provided environment variable: " + f"[yellow]{overrides.env_var}[/yellow]" + ) + elif overrides.no_env_var: + metadata["env_var"] = None + console.print("[yellow]Disabling environment variable as requested[/yellow]") + + +def _update_existing( + directory: Path, existing: CloudMetadataFile, overrides: _Overrides +) -> CloudMetadata: + """Apply ``overrides`` to the directory's metadata and save any change. + + Entries the metadata cannot hold are written back unless an override + replaces them. + """ + metadata, unsupported = existing + console = get_console() + console.print( + f"[green]Found existing metadata file[/green] ({CLOUD_METADATA_FILE})" + ) + console.print(f"Using existing name: [blue]{metadata.get('name')}[/blue]") + env_var = metadata.get("env_var") + if _present(env_var) and not overrides.no_env_var: + console.print( + f"Using existing environment variable: [yellow]{env_var}[/yellow]" + ) + + if overrides.given(): + _apply_overrides(metadata, overrides) + metadata["updated_with"] = "kilm" + write_cloud_metadata(directory, metadata, unsupported) + console.print("[green]Updated metadata file with new information[/green]") + return metadata -def add_3d( +def _create_new( + directory: Path, overrides: _Overrides, *, overwriting: bool +) -> CloudMetadata: + """Write fresh metadata for the directory, applying ``overrides``.""" + console = get_console() + if overwriting: + console.print( + "[yellow]Overwriting existing metadata file[/yellow] " + f"({CLOUD_METADATA_FILE})" + ) + else: + console.print( + f"[green]Creating new metadata file[/green] ({CLOUD_METADATA_FILE})" + ) + + metadata = get_default_cloud_metadata(directory) + if overrides.name is not None: + metadata["name"] = overrides.name + # A new name implies a matching variable unless one is given or refused. + if overrides.env_var is None and not overrides.no_env_var: + metadata["env_var"] = generate_env_var_name(overrides.name, "KICAD_3D") + if overrides.description is not None: + metadata["description"] = overrides.description + if overrides.env_var is not None: + metadata["env_var"] = overrides.env_var + elif overrides.no_env_var: + metadata["env_var"] = None + + write_cloud_metadata(directory, metadata) + console.print("[green]Metadata file created[/green]") + return metadata + + +def _confirm_models(model_count: int) -> None: + """Ask before registering a directory without models; exit on refusal.""" + if model_count > 0: + return + console = get_console() + console.print("[yellow]Warning: No 3D model files found in this directory[/yellow]") + if not typer.confirm("Continue anyway?", default=True): + console.print("[red]Operation cancelled[/red]") + raise typer.Exit(0) + + +def _print_usage(name: str, directory: Path, env_var: str | int | None) -> None: + """Explain how to use the library through its environment variable.""" + if not _present(env_var): + return + console = get_console() + console.print(f"[yellow]Assigned environment variable:[/yellow] {env_var}") + console.print("\n[bold]You can use this directory with:[/bold]") + console.print(f" [cyan]kilm setup --threed-lib-dirs '{name}'[/cyan]") + console.print(" [dim]# or by setting the environment variable[/dim]") + console.print(f" [cyan]export {env_var}='{directory}'[/cyan]") + + +def _print_cloud_libraries(config: Config) -> None: + """List every registered 3D model directory when there is more than one.""" + libraries = config.get_libraries("cloud") + if len(libraries) <= 1: + return + console = get_console() + console.print("\n[bold]All registered cloud-based 3D model directories:[/bold]") + for library in libraries: + name = library.get("name", "unnamed") + path = library.get("path", "unknown") + metadata = read_cloud_metadata(Path(path)) + env_var = None if metadata is None else metadata.get("env_var") + if not _present(env_var): + console.print(f" - [cyan]{name}[/cyan]: {path}") + else: + console.print( + f" - [cyan]{name}[/cyan]: {path} [yellow](ENV: {env_var})[/yellow]" + ) + + +def add_3d( # noqa: PLR0913 -- each CLI option is a Typer parameter + *, name: Annotated[ str | None, typer.Option( @@ -71,182 +229,48 @@ def add_3d( If no directory is specified, the current directory will be used. """ - # Use current directory if not specified directory = Path.cwd().resolve() if directory is None else directory.resolve() - + console = get_console() console.print( f"[bold cyan]Adding cloud-based 3D models directory:[/bold cyan] {directory}" ) - # Check for existing metadata - metadata = read_cloud_metadata(directory) - - if metadata and not force: - console.print( - f"[green]Found existing metadata file[/green] ({CLOUD_METADATA_FILE})" - ) - library_name = metadata.get("name") - library_description = metadata.get("description") - library_env_var = metadata.get("env_var") - console.print(f"Using existing name: [blue]{library_name}[/blue]") - - # Show environment variable if present - if library_env_var and not no_env_var: - console.print( - f"Using existing environment variable: [yellow]{library_env_var}[/yellow]" - ) - - # Override with command line parameters if provided - if name: - library_name = name - console.print(f"Overriding with provided name: [blue]{library_name}[/blue]") - - if description: - library_description = description - console.print( - f"Overriding with provided description: [blue]{library_description}[/blue]" - ) - - if env_var: - library_env_var = env_var - console.print( - f"Overriding with provided environment variable: [yellow]{library_env_var}[/yellow]" - ) - elif no_env_var: - library_env_var = None - console.print( - "[yellow]Disabling environment variable as requested[/yellow]" - ) - - # Update metadata if command line parameters were provided - if name or description or env_var or no_env_var: - metadata["name"] = library_name - metadata["description"] = library_description - if library_env_var and not no_env_var: - metadata["env_var"] = library_env_var - else: - metadata["env_var"] = None - metadata["updated_with"] = "kilm" - write_cloud_metadata(directory, metadata) - console.print("[green]Updated metadata file with new information[/green]") + overrides = _Overrides( + name=given(name), + description=given(description), + env_var=given(env_var), + no_env_var=no_env_var, + ) + existing = read_cloud_metadata_file(directory) + has_existing = existing is not None and bool( + existing.metadata or existing.unsupported + ) + unsupported: dict[object, object] = {} + if existing is not None and has_existing and not force: + metadata = _update_existing(directory, existing, overrides) + unsupported = existing.unsupported else: - # Create a new metadata file - if metadata and force: - console.print( - f"[yellow]Overwriting existing metadata file[/yellow] ({CLOUD_METADATA_FILE})" - ) - else: - console.print( - f"[green]Creating new metadata file[/green] ({CLOUD_METADATA_FILE})" - ) - - # Generate metadata - metadata = get_default_cloud_metadata(directory) - - # Override with command line parameters if provided - if name: - metadata["name"] = name - # If name is provided but env_var isn't, regenerate the env_var based on the new name - if not env_var and not no_env_var: - metadata["env_var"] = generate_env_var_name(name, "KICAD_3D") - - if description: - metadata["description"] = description - - if env_var: - metadata["env_var"] = env_var - elif no_env_var: - metadata["env_var"] = None - - # Write metadata file - write_cloud_metadata(directory, metadata) - console.print("[green]Metadata file created[/green]") - - library_name = metadata["name"] - library_env_var = metadata.get("env_var") - - # Verify if this looks like a 3D model directory - model_extensions = [".step", ".stp", ".wrl", ".wings"] - found_models = False - - # Do a quick check for model files - for ext in model_extensions: - if list(directory.glob(f"**/*{ext}")): - found_models = True - break - - if not found_models: - console.print( - "[yellow]Warning: No 3D model files found in this directory[/yellow]" - ) - if not typer.confirm("Continue anyway?", default=True): - console.print("[red]Operation cancelled[/red]") - raise typer.Exit(0) - - # Update metadata with actual model count - model_count = 0 - for ext in model_extensions: - model_count += len(list(directory.glob(f"**/*{ext}"))) + metadata = _create_new(directory, overrides, overwriting=has_existing) + model_count = count_model_files(directory) + _confirm_models(model_count) metadata["model_count"] = model_count - write_cloud_metadata(directory, metadata) + write_cloud_metadata(directory, metadata, unsupported) - # Update the configuration + raw_name = metadata.get("name") + library_name = directory.name if raw_name is None else str(raw_name) try: config = Config() - # Add as a cloud-based 3D model library - if library_name is None: - library_name = metadata.get("name", directory.name) - - # Ensure library_name is a string - final_library_name = ( - str(library_name) if library_name is not None else directory.name - ) - config.add_library(final_library_name, str(directory), "cloud") - + config.add_library(library_name, str(directory), "cloud") console.print( - f"[bold green]3D models directory '{final_library_name}' added successfully![/bold green]" + f"[bold green]3D models directory '{library_name}' added " + "successfully![/bold green]" ) console.print(f"[blue]Path:[/blue] {directory}") if model_count > 0: console.print(f"[green]Found {model_count} 3D model files[/green]") - - if library_env_var: - console.print( - f"[yellow]Assigned environment variable:[/yellow] {library_env_var}" - ) - console.print("\n[bold]You can use this directory with:[/bold]") - console.print( - f" [cyan]kilm setup --threed-lib-dirs '{library_name}'[/cyan]" - ) - console.print(" [dim]# or by setting the environment variable[/dim]") - console.print(f" [cyan]export {library_env_var}='{directory}'[/cyan]") - - # Show current cloud libraries - libraries = config.get_libraries("cloud") - if len(libraries) > 1: - console.print( - "\n[bold]All registered cloud-based 3D model directories:[/bold]" - ) - for lib in libraries: - lib_name = lib.get("name", "unnamed") - lib_path = lib.get("path", "unknown") - lib_env_var = None - - # Try to get the environment variable from metadata - try: - lib_metadata = read_cloud_metadata(Path(lib_path)) - if lib_metadata and "env_var" in lib_metadata: - lib_env_var = lib_metadata["env_var"] - except Exception: - pass - - if lib_env_var: - console.print( - f" - [cyan]{lib_name}[/cyan]: {lib_path} [yellow](ENV: {lib_env_var})[/yellow]" - ) - else: - console.print(f" - [cyan]{lib_name}[/cyan]: {lib_path}") + _print_usage(library_name, directory, metadata.get("env_var")) + _print_cloud_libraries(config) except Exception as e: console.print(f"[red]Error adding 3D models directory: {e}[/red]") raise typer.Exit(1) from e diff --git a/kicad_lib_manager/commands/add_3d/docs.mdx b/kicad_lib_manager/commands/add_3d/docs.mdx index f583443..2e4f030 100644 --- a/kicad_lib_manager/commands/add_3d/docs.mdx +++ b/kicad_lib_manager/commands/add_3d/docs.mdx @@ -29,10 +29,11 @@ kilm add-3d [OPTIONS] The command performs the following main actions: -1. **Creates/Updates Metadata**: Creates or modifies `.kilm_cloud_metadata` in the target directory containing: +1. **Creates/Updates Metadata**: Creates or modifies `.kilm_metadata` in the target directory containing: - Library name, description, and associated environment variable - Count of detected model files - - Last updated timestamp + + Other fields already in the file are kept as they are, unless an option replaces them or `--force` starts the file afresh. 2. **Updates Global Configuration**: Adds library entry (type `cloud`) to main `config.yaml` @@ -155,5 +156,5 @@ The command automatically detects these 3D model file extensions: - **Environment Variables**: Use `kilm setup` to activate the configured environment variables in KiCad - **Cross-platform**: Works on Windows, macOS, and Linux with appropriate path handling -- **Metadata Storage**: Library information is stored in `.kilm_cloud_metadata` in the target directory +- **Metadata Storage**: Library information is stored in `.kilm_metadata` in the target directory - **KiCad Integration**: Requires `kilm setup` to complete the integration with KiCad diff --git a/kicad_lib_manager/commands/add_hook/__init__.py b/kicad_lib_manager/commands/add_hook/__init__.py index e802f9f..b55a5a6 100644 --- a/kicad_lib_manager/commands/add_hook/__init__.py +++ b/kicad_lib_manager/commands/add_hook/__init__.py @@ -1,3 +1,5 @@ -from .command import add_hook +"""The ``add-hook`` command: install a git post-merge hook that runs ``kilm sync``.""" + +from kicad_lib_manager.commands.add_hook.command import add_hook __all__ = ["add_hook"] diff --git a/kicad_lib_manager/commands/add_hook/command.py b/kicad_lib_manager/commands/add_hook/command.py index f7c263e..d472ae0 100644 --- a/kicad_lib_manager/commands/add_hook/command.py +++ b/kicad_lib_manager/commands/add_hook/command.py @@ -1,25 +1,103 @@ -""" -Add Hook command implementation for KiCad Library Manager. -Adds a git post-merge hook to the current repository to automatically update KiCad libraries. +"""The ``add-hook`` command: run ``kilm sync`` after every git pull or merge. + +The command installs a git ``post-merge`` hook. An existing hook is backed up +and, unless ``--force`` is given, keeps its own commands next to the KiLM +section. """ from pathlib import Path from typing import Annotated import typer -from rich.console import Console -from ...utils.git_utils import ( +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.file_ops import write_atomically +from kicad_lib_manager.utils.git_utils import ( backup_existing_hook, create_kilm_hook_content, get_git_hooks_directory, merge_hook_content, ) -console = Console() +HOOK_NAME = "post-merge" +MANAGED_SECTION_MARKER = "KiLM-managed section" + + +def _hooks_directory(repository: Path) -> Path: + """Return the hooks directory git uses for ``repository``; exit if none.""" + try: + hooks_dir = get_git_hooks_directory(repository) + except RuntimeError as e: + get_console().print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) from e + get_console().print(f"[blue]Using hooks directory: {hooks_dir}[/blue]") + return hooks_dir + + +def _content_replacing(hook: Path, *, force: bool) -> str | None: + """Back up the existing ``hook`` and return the content to replace it with. + + Without ``force`` the user must confirm, and the existing commands are + kept around the KiLM section. Returns None when the user declines. + """ + console = get_console() + if not force: + console.print(f"[yellow]Post-merge hook already exists at {hook}[/yellow]") + if not typer.confirm("Overwrite existing hook?", default=False): + console.print("[yellow]Hook installation cancelled.[/yellow]") + return None + + backup_path = backup_existing_hook(hook) + console.print(f"[green]Created backup of existing hook: {backup_path}[/green]") + try: + existing_content = hook.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + console.print( + "[yellow]Warning: Could not read existing hook content, " + "overwriting...[/yellow]" + ) + return create_kilm_hook_content() + if force: + console.print( + "[yellow]Force overwrite requested, replacing existing hook...[/yellow]" + ) + return create_kilm_hook_content() + console.print("[cyan]Merging KiLM content with existing hook...[/cyan]") + return merge_hook_content(existing_content, create_kilm_hook_content()) + + +def _install(hook: Path, content: str) -> None: + """Write ``content`` to ``hook``, make it executable, and explain it.""" + console = get_console() + try: + # Bytes, because text mode would give the shell script CRLF endings on + # Windows. + write_atomically(hook, content.encode("utf-8")) + hook.chmod(0o755) + except Exception as e: + console.print(f"[red]Error creating hook: {e!s}[/red]") + raise typer.Exit(1) from e + + console.print(f"[green]Successfully installed post-merge hook at {hook}[/green]") + console.print( + "[blue]The hook will run 'kilm sync' after every 'git pull' or " + "'git merge' operation.[/blue]" + ) + if MANAGED_SECTION_MARKER in content: + console.print( + "\n[cyan]Note: The hook contains clear markers for KiLM-managed " + "sections,[/cyan]" + ) + console.print("[cyan]making future updates safe and idempotent.[/cyan]") + console.print( + "\n[blue]Note: You may need to modify the hook script if you want to " + "customize[/blue]" + ) + console.print("[blue]the update behavior or automatically set up libraries.[/blue]") def add_hook( + *, directory: Annotated[ Path | None, typer.Option( @@ -39,100 +117,18 @@ def add_hook( This ensures your KiCad libraries are always up-to-date after pulling changes from remote repositories. """ - try: - # Determine target directory - target_dir = directory if directory else Path.cwd() - - console.print(f"[cyan]Adding Git hook to repository: {target_dir}[/cyan]") + target_dir = Path.cwd() if directory is None else directory + get_console().print(f"[cyan]Adding Git hook to repository: {target_dir}[/cyan]") + hook = _hooks_directory(target_dir) / HOOK_NAME + if not hook.exists(): + content = create_kilm_hook_content() + else: try: - # Get the active hooks directory (handles custom paths, worktrees, etc.) - hooks_dir = get_git_hooks_directory(target_dir) - console.print(f"[blue]Using hooks directory: {hooks_dir}[/blue]") - - except RuntimeError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - - # Check if post-merge hook already exists - post_merge_hook = hooks_dir / "post-merge" - - if post_merge_hook.exists(): - if not force: - console.print( - f"[yellow]Post-merge hook already exists at {post_merge_hook}[/yellow]" - ) - overwrite = typer.confirm("Overwrite existing hook?", default=False) - if not overwrite: - console.print("[yellow]Hook installation cancelled.[/yellow]") - return - - # Create backup of existing hook - backup_path = backup_existing_hook(post_merge_hook) - console.print( - f"[green]Created backup of existing hook: {backup_path}[/green]" - ) - - # Read existing content for potential merging - try: - existing_content = post_merge_hook.read_text(encoding="utf-8") - - if force: - # Force overwrite - don't merge, just replace - console.print( - "[yellow]Force overwrite requested, replacing existing hook...[/yellow]" - ) - new_content = create_kilm_hook_content() - else: - # Merge with existing content to preserve user logic - console.print( - "[cyan]Merging KiLM content with existing hook...[/cyan]" - ) - new_content = merge_hook_content( - existing_content, create_kilm_hook_content() - ) - - except (OSError, UnicodeDecodeError): - console.print( - "[yellow]Warning: Could not read existing hook content, overwriting...[/yellow]" - ) - new_content = create_kilm_hook_content() - else: - # No existing hook, create new one - new_content = create_kilm_hook_content() - - try: - # Write the hook content - with post_merge_hook.open("w") as f: - f.write(new_content) - - # Make the hook executable - post_merge_hook.chmod(0o755) - - console.print( - f"[green]Successfully installed post-merge hook at {post_merge_hook}[/green]" - ) - console.print( - "[blue]The hook will run 'kilm sync' after every 'git pull' or 'git merge' operation.[/blue]" - ) - - if post_merge_hook.exists() and "KiLM-managed section" in new_content: - console.print( - "\n[cyan]Note: The hook contains clear markers for KiLM-managed sections,[/cyan]" - ) - console.print("[cyan]making future updates safe and idempotent.[/cyan]") - - console.print( - "\n[blue]Note: You may need to modify the hook script if you want to customize[/blue]" - ) - console.print( - "[blue]the update behavior or automatically set up libraries.[/blue]" - ) - + content = _content_replacing(hook, force=force) except Exception as e: - console.print(f"[red]Error creating hook: {str(e)}[/red]") + get_console().print(f"[red]Error: {e}[/red]") raise typer.Exit(1) from e - - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e + if content is None: + return + _install(hook, content) diff --git a/kicad_lib_manager/commands/config/__init__.py b/kicad_lib_manager/commands/config/__init__.py index e5b7a23..ec340b9 100644 --- a/kicad_lib_manager/commands/config/__init__.py +++ b/kicad_lib_manager/commands/config/__init__.py @@ -1,8 +1,9 @@ +"""The `kilm config` command group.""" + import typer -from .command import list_config, remove, set_default +from kicad_lib_manager.commands.config.command import list_config, remove, set_default -# Create Typer app with subcommands config_app = typer.Typer( name="config", help="Manage KiCad Library Manager configuration", @@ -10,11 +11,10 @@ no_args_is_help=True, ) -# Add subcommands config_app.command("list", help="List all configured libraries in kilm")(list_config) config_app.command("set-default", help="Set a library as the default for operations")( set_default ) config_app.command("remove", help="Remove a library from the configuration")(remove) -__all__ = ["config_app", "list_config", "set_default", "remove"] +__all__ = ["config_app", "list_config", "remove", "set_default"] diff --git a/kicad_lib_manager/commands/config/command.py b/kicad_lib_manager/commands/config/command.py index bd136c0..145fa33 100644 --- a/kicad_lib_manager/commands/config/command.py +++ b/kicad_lib_manager/commands/config/command.py @@ -1,34 +1,77 @@ -""" -Configuration commands implementation for KiCad Library Manager. -Provides commands for managing KiCad Library Manager configuration. -""" +"""Commands that list, select, and remove the libraries in the KiLM configuration.""" -import sys +from dataclasses import dataclass from pathlib import Path -from typing import Annotated +from typing import Annotated, Final +import click import typer from rich.console import Console +from rich.markup import escape from rich.panel import Panel from rich.table import Table -from ...services.config_service import Config -from ...utils.metadata import ( +from kicad_lib_manager.services.config_service import Config, LibraryDict +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import ( CLOUD_METADATA_FILE, GITHUB_METADATA_FILE, +) +from kicad_lib_manager.utils.metadata import ( + LIBRARY_FOLDERS, + count_model_files, read_cloud_metadata, read_github_metadata, ) -console = Console() + +@dataclass(frozen=True) +class LibraryKind: + """How `config list` presents one library type.""" + + heading: str + contents: str + missing: str + """Shown when no library of this type is configured.""" + + +LIBRARY_KINDS: Final = { + "github": LibraryKind( + heading="GitHub Libraries", + contents="symbols, footprints, templates", + missing="[yellow]No GitHub libraries configured.[/yellow]\n\n" + "[cyan]Get Started:[/cyan]\n" + "• Initialize a GitHub library: [blue]kilm init[/blue]", + ), + "cloud": LibraryKind( + heading="Cloud Libraries", + contents="3D models", + missing="[yellow]No cloud libraries configured.[/yellow]\n\n" + "[cyan]Get Started:[/cyan]\n" + "• Add a 3D model library: [blue]kilm add-3d[/blue]", + ), +} +"""The library types `config list` shows, in display order.""" + +NO_LIBRARIES: Final = ( + "[yellow]No libraries configured.[/yellow]\n\n" + "[cyan]Get Started:[/cyan]\n" + "• Initialize GitHub library: [blue]kilm init[/blue]\n" + "• Add 3D model library: [blue]kilm add-3d[/blue]\n" + "• Check status: [blue]kilm status[/blue]" +) + +LIST_HINT: Final = "Use 'kilm config list' to see available libraries." def list_config( + *, library_type: Annotated[ str, typer.Option( "--type", - help="Type of libraries to list (github=symbols/footprints, cloud=3D models)", + help="Type of libraries to list" + " (github=symbols/footprints, cloud=3D models)", ), ] = "all", verbose: Annotated[ @@ -36,409 +79,246 @@ def list_config( typer.Option("-v", "--verbose", help="Show more information about libraries"), ] = False, ) -> None: - """List all configured libraries in kilm. + """List the configured libraries of one type or of all types. - This shows all libraries stored in the kilm configuration file. - There are two types of libraries: - - 1. GitHub libraries - containing symbols and footprints (type: github) - 2. Cloud libraries - containing 3D models (type: cloud) - - Use --verbose to see metadata information stored in the library directories. + With --verbose, each library also shows the metadata stored in its + directory. """ - try: - config = Config() - - # Get libraries of specified type - if library_type == "all": - libraries = config.get_libraries() - else: - libraries = config.get_libraries(library_type) - - # Get current library - current_library = config.get_current_library() - - if not libraries: - console.print() - console.print( - Panel( - "[yellow]No libraries configured.[/yellow]\n\n" - "[cyan]Get Started:[/cyan]\n" - "• Initialize GitHub library: [blue]kilm init[/blue]\n" - "• Add 3D model library: [blue]kilm add-3d[/blue]\n" - "• Check status: [blue]kilm status[/blue]", - title="[bold yellow]⚠️ No Libraries[/bold yellow]", - border_style="yellow", - ) + config = Config() + libraries = config.get_libraries(None if library_type == "all" else library_type) + console = get_console() + console.print() + if not libraries: + kind = LIBRARY_KINDS.get(library_type) + title = f"No {kind.heading}" if kind else "No Libraries" + console.print( + Panel( + kind.missing if kind else NO_LIBRARIES, + title=f"[bold yellow]{title}[/bold yellow]", + border_style="yellow", ) - return + ) + return + + current = config.get_current_library() + counts: dict[str, int] = {} + for type_name, kind in LIBRARY_KINDS.items(): + of_type = [library for library in libraries if library["type"] == type_name] + counts[type_name] = len(of_type) + if of_type: + _print_section(console, kind, of_type, current, verbose=verbose) + + console.print( + Panel( + f"[green]Total Libraries:[/green] {len(libraries)}\n" + f"[cyan]GitHub:[/cyan] {counts['github']} " + f"[blue]Cloud:[/blue] {counts['cloud']}", + title="[bold cyan]Summary[/bold cyan]", + border_style="cyan", + width=35, + ) + ) - # Group libraries by type - types = {"github": [], "cloud": []} - for lib in libraries: - lib_type = lib.get("type", "unknown") - if lib_type in types: - types[lib_type].append(lib) +def _print_section( + console: Console, + kind: LibraryKind, + libraries: list[LibraryDict], + current: str | None, + *, + verbose: bool, +) -> None: + """Print the libraries of one type as a table, or as panels when verbose.""" + console.print( + f"\n[bold cyan]{kind.heading}[/bold cyan] [dim]({kind.contents})[/dim]" + ) + console.print() + if not verbose: + console.print(_library_table(libraries, current)) console.print() - - # Display GitHub Libraries - if library_type in ["all", "github"] and types["github"]: - console.print( - "\n[bold cyan]GitHub Libraries[/bold cyan] [dim](symbols, footprints, templates)[/dim]" - ) - console.print() - - if verbose: - # Verbose mode: Individual panels for each library - for lib in types["github"]: - name = lib.get("name", "unnamed") - path = lib.get("path", "unknown") - path_obj = Path(path) - - # Get metadata - metadata = read_github_metadata(path_obj) - - # Build content - content = f"[blue]Path:[/blue] {path}\n" - - if metadata: - if "description" in metadata: - content += f"[green]Description:[/green] {metadata['description']}\n" - if "version" in metadata: - content += ( - f"[yellow]Version:[/yellow] {metadata['version']}\n" - ) - if "env_var" in metadata and metadata["env_var"]: - content += f"[magenta]Environment Variable:[/magenta] {metadata['env_var']}\n" - - # Capabilities with clear labels - if "capabilities" in metadata: - caps = metadata["capabilities"] - if isinstance(caps, dict): - content += "[white]Features:[/white] " - features = [] - if caps.get("symbols"): - features.append("[green]✓ Symbols[/green]") - else: - features.append("[red]✗ Symbols[/red]") - if caps.get("footprints"): - features.append("[green]✓ Footprints[/green]") - else: - features.append("[red]✗ Footprints[/red]") - if caps.get("templates"): - features.append("[green]✓ Templates[/green]") - else: - features.append("[red]✗ Templates[/red]") - content += " | ".join(features) - else: - content += ( - f"[dim]No {GITHUB_METADATA_FILE} metadata file found[/dim]" - ) - - # Status indicator - status = ( - "[green]✓ CURRENT[/green]" - if path == current_library - else "[dim]Available[/dim]" - ) - title = f"[bold cyan]{name}[/bold cyan] [{status}]" - - console.print( - Panel( - content, - title=title, - border_style="cyan" if path == current_library else "dim", - ) - ) - console.print() - else: - # Compact mode: Simple table - table = Table( - show_header=True, header_style="bold magenta", border_style="cyan" - ) - - table.add_column("Library", style="cyan", no_wrap=True) - table.add_column("Status", justify="center", style="green", width=12) - table.add_column("Path", style="blue") - - for lib in types["github"]: - name = lib.get("name", "unnamed") - path = lib.get("path", "unknown") - - status = ( - "[green]✓ Current[/green]" - if path == current_library - else "[dim]Available[/dim]" - ) - - table.add_row(f"[bold]{name}[/bold]", status, path) - - console.print(table) - console.print() - - # Display Cloud Libraries - if library_type in ["all", "cloud"] and types["cloud"]: - console.print( - "\n[bold cyan]Cloud Libraries[/bold cyan] [dim](3D models)[/dim]" - ) - console.print() - - if verbose: - # Verbose mode: Individual panels for each library - for lib in types["cloud"]: - name = lib.get("name", "unnamed") - path = lib.get("path", "unknown") - path_obj = Path(path) - - # Get metadata - metadata = read_cloud_metadata(path_obj) - - # Build content - content = f"[blue]Path:[/blue] {path}\n" - - if metadata: - if "description" in metadata: - content += f"[green]Description:[/green] {metadata['description']}\n" - if "version" in metadata: - content += ( - f"[yellow]Version:[/yellow] {metadata['version']}\n" - ) - if "env_var" in metadata and metadata["env_var"]: - content += f"[magenta]Environment Variable:[/magenta] {metadata['env_var']}\n" - - # Model count - if "model_count" in metadata: - content += ( - f"[white]3D Models:[/white] {metadata['model_count']}" - ) - else: - # Count models - count = 0 - for ext in [".step", ".stp", ".wrl", ".wings"]: - count += len(list(path_obj.glob(f"**/*{ext}"))) - content += f"[white]3D Models:[/white] {count} [dim](counted)[/dim]" - else: - content += ( - f"[dim]No {CLOUD_METADATA_FILE} metadata file found[/dim]" - ) - # Still count models - count = 0 - for ext in [".step", ".stp", ".wrl", ".wings"]: - count += len(list(path_obj.glob(f"**/*{ext}"))) - content += ( - f"\n[white]3D Models:[/white] {count} [dim](counted)[/dim]" - ) - - # Status indicator - status = ( - "[green]✓ CURRENT[/green]" - if path == current_library - else "[dim]Available[/dim]" - ) - title = f"[bold cyan]{name}[/bold cyan] [{status}]" - - console.print( - Panel( - content, - title=title, - border_style="cyan" if path == current_library else "dim", - ) - ) - console.print() - else: - # Compact mode: Simple table - table = Table( - show_header=True, header_style="bold magenta", border_style="cyan" - ) - - table.add_column("Library", style="cyan", no_wrap=True) - table.add_column("Status", justify="center", style="green", width=12) - table.add_column("Path", style="blue") - - for lib in types["cloud"]: - name = lib.get("name", "unnamed") - path = lib.get("path", "unknown") - - status = ( - "[green]✓ Current[/green]" - if path == current_library - else "[dim]Available[/dim]" - ) - - table.add_row(f"[bold]{name}[/bold]", status, path) - - console.print(table) - console.print() - - # Add summary panel - total_libs = len(libraries) - github_count = len(types["github"]) - cloud_count = len(types["cloud"]) - - summary_content = f"[green]Total Libraries:[/green] {total_libs}\n" - summary_content += ( - f"[cyan]GitHub:[/cyan] {github_count} [blue]Cloud:[/blue] {cloud_count}" - ) - + return + for library in libraries: + is_current = library["path"] == current + status = "[green]CURRENT[/green]" if is_current else "[dim]Available[/dim]" console.print( Panel( - summary_content, - title="[bold cyan]Summary[/bold cyan]", - border_style="cyan", - width=35, + "\n".join(_library_details(library)), + title=f"[bold cyan]{escape(library['name'])}[/bold cyan] [{status}]", + border_style="cyan" if is_current else "dim", ) ) + console.print() - # Print helpful message if no libraries match the filter - if library_type == "github" and not types["github"]: - console.print( - Panel( - "[yellow]No GitHub libraries configured.[/yellow]\n\n" - "[cyan]Get Started:[/cyan]\n" - "• Initialize a GitHub library: [blue]kilm init[/blue]", - title="[bold yellow]⚠️ No GitHub Libraries[/bold yellow]", - border_style="yellow", - ) - ) - elif library_type == "cloud" and not types["cloud"]: - console.print( - Panel( - "[yellow]No cloud libraries configured.[/yellow]\n\n" - "[cyan]Get Started:[/cyan]\n" - "• Add a 3D model library: [blue]kilm add-3d[/blue]", - title="[bold yellow]⚠️ No Cloud Libraries[/bold yellow]", - border_style="yellow", - ) - ) - except Exception as e: - console.print(f"[red]Error listing configurations: {e}[/red]") - sys.exit(1) +def _library_table(libraries: list[LibraryDict], current: str | None) -> Table: + """Return a table of library names, default marker, and paths.""" + table = Table(show_header=True, header_style="bold magenta", border_style="cyan") + table.add_column("Library", style="cyan", no_wrap=True) + table.add_column("Status", justify="center", style="green", width=12) + table.add_column("Path", style="blue") + for library in libraries: + status = ( + "[green]Current[/green]" + if library["path"] == current + else "[dim]Available[/dim]" + ) + table.add_row( + f"[bold]{escape(library['name'])}[/bold]", status, escape(library["path"]) + ) + return table + + +def _library_details(library: LibraryDict) -> list[str]: + """Return the verbose lines for a library: path and its metadata.""" + path = Path(library["path"]) + lines = [f"[blue]Path:[/blue] {escape(library['path'])}"] + if library["type"] == "cloud": + lines += _cloud_details(path) + else: + lines += _github_details(path) + return lines + + +def _github_details(path: Path) -> list[str]: + """Return the metadata lines of a symbol and footprint library.""" + metadata = read_github_metadata(path) + if metadata is None: + return [f"[dim]No {GITHUB_METADATA_FILE} metadata file found[/dim]"] + lines = _common_details( + metadata.get("description"), metadata.get("version"), metadata.get("env_var") + ) + capabilities = metadata.get("capabilities") + if capabilities is not None: + features = [ + folder.capitalize() + for folder in LIBRARY_FOLDERS + if capabilities.get(folder) + ] + lines.append(f"[white]Features:[/white] {', '.join(features) or 'none'}") + return lines + + +def _cloud_details(path: Path) -> list[str]: + """Return the metadata lines of a 3D model library, counting models if needed.""" + metadata = read_cloud_metadata(path) + if metadata is None: + return [ + f"[dim]No {CLOUD_METADATA_FILE} metadata file found[/dim]", + _counted_models(path), + ] + lines = _common_details( + metadata.get("description"), metadata.get("version"), metadata.get("env_var") + ) + model_count = metadata.get("model_count") + if model_count is None: + lines.append(_counted_models(path)) + else: + lines.append(f"[white]3D Models:[/white] {model_count}") + return lines + + +def _common_details( + description: str | None, version: str | None, env_var: str | None +) -> list[str]: + """Return the description, version, and environment variable lines that are set.""" + lines: list[str] = [] + if description is not None: + lines.append(f"[green]Description:[/green] {escape(description)}") + if version is not None: + lines.append(f"[yellow]Version:[/yellow] {escape(version)}") + if env_var is not None and env_var: + lines.append(f"[magenta]Environment Variable:[/magenta] {escape(env_var)}") + return lines + + +def _counted_models(path: Path) -> str: + """Return the model count line for a library by counting its model files.""" + count = count_model_files(path) + return f"[white]3D Models:[/white] {count} [dim](counted)[/dim]" def set_default( library_name: Annotated[ str | None, typer.Argument(help="Name of library to set as default") ] = None, + *, library_type: Annotated[ str, typer.Option( "--type", - help="Type of library to set as default (github=symbols/footprints, cloud=3D models)", + help="Type of library to set as default" + " (github=symbols/footprints, cloud=3D models)", ), ] = "github", ) -> None: - """Set a library as the default for operations. - - Sets the specified library as the default for future operations. - The default library is used by commands when no specific library is specified. - - If LIBRARY_NAME is not provided, the command will prompt you to select - from the available libraries of the specified type. - - Examples: - - \b - # Set a GitHub library as default - kilm config set-default my-library - - \b - # Set a Cloud library as default - kilm config set-default my-3d-library --type cloud - - \b - # Interactively select a library to set as default - kilm config set-default + """Make a library of the given type the default; prompt when no name is given. - \b - # Interactively select a Cloud library to set as default - kilm config set-default --type cloud + Exits with status 1 when no such library exists or the configuration + cannot be saved. """ - try: - config = Config() - - # Get libraries of specified type - libraries = config.get_libraries(library_type) - - if not libraries: - console.print(f"No {library_type} libraries configured.") - if library_type == "github": - console.print("Use 'kilm init' to initialize a GitHub library.") - else: - console.print( - "Use 'kilm add-3d' to add a cloud-based 3D model library." - ) - sys.exit(1) - - # Get current library path - current_library = config.get_current_library() - - # If library name not provided, prompt for selection - if not library_name: - console.print(f"\nAvailable {library_type} libraries:") - - # Show numbered list of libraries - for i, lib in enumerate(libraries): - name = lib.get("name", "unnamed") - path = lib.get("path", "unknown") - - # Mark current library - current_marker = "" - if path == current_library: - current_marker = " (current)" - - console.print(f"{i + 1}. {name}{current_marker}") - - # Get selection - while True: - try: - selection = typer.prompt( - "Select library (number)", type=int, default=1 - ) - if 1 <= selection <= len(libraries): - selected_lib = libraries[selection - 1] - library_name = selected_lib.get("name") - library_path = selected_lib.get("path") - break - else: - console.print( - f"Please enter a number between 1 and {len(libraries)}" - ) - except ValueError: - console.print("Please enter a valid number") - else: - # Find the library by name - library_path = None - for lib in libraries: - if lib.get("name") == library_name: - library_path = lib.get("path") - break - - if not library_path: - console.print( - f"No {library_type} library named '{library_name}' found." - ) - console.print("Use 'kilm config list' to see available libraries.") - sys.exit(1) - - # Set as current library - if library_path is None: + console = get_console() + config = Config() + libraries = config.get_libraries(library_type) + if not libraries: + console.print(f"No {library_type} libraries configured.", markup=False) + console.print( + "Use 'kilm init' to initialize a GitHub library." + if library_type == "github" + else "Use 'kilm add-3d' to add a cloud-based 3D model library." + ) + raise typer.Exit(1) + + if library_name is None: + library = _prompt_for_library( + console, libraries, config.get_current_library(), library_type + ) + else: + library = next( + (library for library in libraries if library["name"] == library_name), + None, + ) + if library is None: console.print( - f"[red]Error: Could not find path for library '{library_name}'[/red]" + f"No {library_type} library named '{library_name}' found.", markup=False ) - sys.exit(1) - config.set_current_library(library_path) - console.print(f"Set {library_type} library '{library_name}' as default.") - console.print(f"Path: {library_path}") + console.print(LIST_HINT) + raise typer.Exit(1) - except Exception as e: - console.print(f"[red]Error setting default library: {e}[/red]") - sys.exit(1) + try: + config.set_current_library(library["path"]) + except OSError as e: + console.print(f"[red]Error setting default library: {escape(str(e))}[/red]") + raise typer.Exit(1) from e + console.print( + f"Set {library_type} library '{library['name']}' as default.", markup=False + ) + console.print(f"Path: {library['path']}", markup=False) + + +def _prompt_for_library( + console: Console, + libraries: list[LibraryDict], + current: str | None, + library_type: str, +) -> LibraryDict: + """Show a numbered list of libraries and return the one the user picks.""" + console.print(f"\nAvailable {library_type} libraries:", markup=False) + for number, library in enumerate(libraries, start=1): + marker = " (current)" if library["path"] == current else "" + console.print(f"{number}. {library['name']}{marker}", markup=False) + selection = int( + typer.prompt( + "Select library (number)", + type=click.IntRange(1, len(libraries)), + default=1, + ) + ) + return libraries[selection - 1] def remove( library_name: Annotated[str, typer.Argument(help="Name of library to remove")], + *, library_type: Annotated[ str, typer.Option( @@ -450,94 +330,62 @@ def remove( bool, typer.Option("--force", help="Force removal without confirmation") ] = False, ) -> None: - """Remove a library from the configuration. - - Removes the specified library from the KiCad Library Manager configuration. - This does not delete any files, it only removes the library from the configuration. + """Unregister a library of one type or of every type, after confirmation. - Examples: + Library files stay untouched. Exits with status 1 when no such library + exists or the configuration cannot be saved. + """ + console = get_console() + config = Config() + any_type = library_type == "all" + matches = [ + library + for library in config.get_libraries(None if any_type else library_type) + if library["name"] == library_name + ] + if not matches: + described = "library" if any_type else f"{library_type} library" + console.print(f"No {described} named '{library_name}' found.", markup=False) + console.print(LIST_HINT) + raise typer.Exit(1) + + current = config.get_current_library() + if not force and not _confirm_removal(console, matches, current): + console.print("Operation cancelled.") + return - \b - # Remove a library (prompts for confirmation) - kilm config remove my-library + try: + config.remove_library(library_name, None if any_type else library_type) + except OSError as e: + console.print(f"[red]Error removing library: {escape(str(e))}[/red]") + raise typer.Exit(1) from e + if len(matches) == 1: + console.print( + f"Removed library '{library_name}' from configuration.", markup=False + ) + else: + console.print( + f"Removed {len(matches)} instances of library '{library_name}'" + " from configuration.", + markup=False, + ) + if current is not None and config.get_current_library() != current: + console.print( + "Note: The removed library was the default; no default is set now." + ) - \b - # Remove a specific library type - kilm config remove my-library --type github - \b - # Force removal without confirmation - kilm config remove my-library --force - """ - try: - config = Config() - - # Get current library path - current_library = config.get_current_library() - - # Get all libraries - all_libraries = config.get_libraries() - - # 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 - ): - matching_libraries.append(lib) - - if not matching_libraries: - if library_type == "all": - console.print(f"No library named '{library_name}' found.") - else: - console.print( - f"No {library_type} library named '{library_name}' found." - ) - console.print("Use 'kilm config list' to see available libraries.") - sys.exit(1) - - # Confirm removal - if not force: - for lib in matching_libraries: - lib_type = lib.get("type", "unknown") - lib_path = lib.get("path", "unknown") - console.print( - f"Will remove {lib_type} library '{library_name}' from configuration." - ) - console.print(f"Path: {lib_path}") - - if lib_path == current_library: - console.print("Warning: This is the current default library.") - - if not typer.confirm("Continue?"): - console.print("Operation cancelled.") - return - - # Remove libraries - removed_count = 0 - for lib in matching_libraries: - lib_type = lib.get("type", "unknown") - removed = config.remove_library(library_name, lib_type) - if removed: - removed_count += 1 - - if removed_count > 0: - if removed_count == 1: - console.print(f"Removed library '{library_name}' from configuration.") - else: - console.print( - f"Removed {removed_count} instances of library '{library_name}' from configuration." - ) - - # Check if we removed the current library - current_library_new = config.get_current_library() - if current_library and current_library != current_library_new: - console.print( - "Note: Default library was changed as the previous default was removed." - ) - else: - console.print("No libraries were removed.") - - except Exception as e: - console.print(f"[red]Error removing library: {e}[/red]") - sys.exit(1) +def _confirm_removal( + console: Console, libraries: list[LibraryDict], current: str | None +) -> bool: + """Describe the libraries about to be removed and ask whether to continue.""" + for library in libraries: + console.print( + f"Will remove {library['type']} library '{library['name']}'" + " from configuration.", + markup=False, + ) + console.print(f"Path: {library['path']}", markup=False) + if library["path"] == current: + console.print("Warning: This is the current default library.") + return typer.confirm("Continue?") diff --git a/kicad_lib_manager/commands/config/docs.mdx b/kicad_lib_manager/commands/config/docs.mdx index 4694a77..dd0a17f 100644 --- a/kicad_lib_manager/commands/config/docs.mdx +++ b/kicad_lib_manager/commands/config/docs.mdx @@ -30,7 +30,7 @@ kilm config list [OPTIONS] _Example:_ `kilm config list --type github` - `-v, --verbose`: - Displays more detailed information about each library, reading from both `config.yaml` and the library's metadata file (`kilm.yaml` or `.kilm_cloud_metadata`). Shows path, description, version, environment variable, capabilities/model count, etc. + Displays more detailed information about each library, reading from both `config.yaml` and the library's metadata file (`kilm.yaml` or `.kilm_metadata`). Shows path, description, version, environment variable, and the library's features (symbols, footprints, templates) or 3D model count. Without a stated model count, the `.step`, `.stp`, `.wrl`, and `.wings` files are counted. _Example:_ `kilm config list --verbose` - `--help`: @@ -117,7 +117,7 @@ kilm config set-default --type cloud -Removes a registered library from the KiLM configuration (`config.yaml`). This does _not_ delete the library files or its metadata file, only the entry in KiLM's central config. +Removes a registered library from the KiLM configuration (`config.yaml`). This does _not_ delete the library files or its metadata file, only the entry in KiLM's central config. Removing the default library clears the default, so commands fall back to the first registered library of each type. #### Usage diff --git a/kicad_lib_manager/commands/import_zip/__init__.py b/kicad_lib_manager/commands/import_zip/__init__.py index 77e81a9..fab609c 100644 --- a/kicad_lib_manager/commands/import_zip/__init__.py +++ b/kicad_lib_manager/commands/import_zip/__init__.py @@ -1,3 +1,5 @@ -from .command import import_zip +"""The import command, which adds the parts in vendor KiCad ZIPs to a library.""" + +from kicad_lib_manager.commands.import_zip.command import import_zip __all__ = ["import_zip"] diff --git a/kicad_lib_manager/commands/import_zip/archive.py b/kicad_lib_manager/commands/import_zip/archive.py index c5f5932..18ba01f 100644 --- a/kicad_lib_manager/commands/import_zip/archive.py +++ b/kicad_lib_manager/commands/import_zip/archive.py @@ -4,19 +4,16 @@ import zlib from pathlib import Path, PurePosixPath -from .errors import ImportFailure +from kicad_lib_manager.commands.import_zip.errors import ImportZipError - -def _lzma_errors() -> tuple[type[Exception], ...]: - """LZMAError, or nothing on a Python built without the lzma module. - - zipfile then reports LZMA members as RuntimeError instead. - """ - try: - from lzma import LZMAError - except ImportError: - return () - return (LZMAError,) +_LZMA_ERRORS: tuple[type[Exception], ...] +try: + from lzma import LZMAError +except ImportError: + # Python built without lzma: zipfile reports LZMA members as RuntimeError. + _LZMA_ERRORS = () +else: + _LZMA_ERRORS = (LZMAError,) # What zipfile raises for a corrupt archive, including the errors it lets @@ -28,7 +25,7 @@ def _lzma_errors() -> tuple[type[Exception], ...]: zlib.error, EOFError, UnicodeDecodeError, - *_lzma_errors(), + *_LZMA_ERRORS, ) MODEL_SUFFIXES = (".stp", ".step", ".stp.gz", ".step.gz") @@ -61,7 +58,7 @@ def extract_part_files(zip_path: Path, dest: Path) -> list[Path]: Returns the extracted paths in member name order. Raises: - ImportFailure: The archive is corrupt, has a member that would land + ImportZipError: The archive is corrupt, has a member that would land outside dest, or has a part file that is encrypted or uses a compression method zipfile cannot read. """ @@ -79,15 +76,17 @@ def extract_part_files(zip_path: Path, dest: Path) -> list[Path]: for i, member in enumerate(members) ] except _CORRUPT_ARCHIVE_ERRORS as exc: - raise ImportFailure(f"invalid or corrupt ZIP: {exc}") from exc + msg = f"invalid or corrupt ZIP: {exc}" + raise ImportZipError(msg) from exc except (RuntimeError, NotImplementedError) as exc: # What zipfile raises for members it cannot decrypt or decompress # (e.g. without the bz2 or lzma module) that the checks did not catch. - raise ImportFailure(f"unreadable archive: {exc}") from exc + msg = f"unreadable archive: {exc}" + raise ImportZipError(msg) from exc def _reject_unsafe_paths(zf: zipfile.ZipFile, dest: Path) -> None: - """Raise ImportFailure if any member would resolve outside dest (zip-slip). + """Raise ImportZipError if any member would resolve outside dest (zip-slip). zipfile sanitises names on extraction too; an archive built to escape is rejected as a whole rather than trusted for its other members. @@ -96,7 +95,8 @@ def _reject_unsafe_paths(zf: zipfile.ZipFile, dest: Path) -> None: for name in zf.namelist(): resolved = (dest / name).resolve() if resolved != root and not resolved.is_relative_to(root): - raise ImportFailure(f"unsafe ZIP entry rejected: {name!r}") + msg = f"unsafe ZIP entry rejected: {name!r}" + raise ImportZipError(msg) def _is_part_file(member: zipfile.ZipInfo) -> bool: @@ -110,11 +110,13 @@ def _is_part_file(member: zipfile.ZipInfo) -> bool: def _require_readable(member: zipfile.ZipInfo) -> None: if member.flag_bits & _ENCRYPTED_FLAG: - raise ImportFailure( + msg = ( f"{member.filename} is encrypted; password-protected ZIPs are not supported" ) + raise ImportZipError(msg) if member.compress_type not in _SUPPORTED_COMPRESSION: - raise ImportFailure( + msg = ( f"{member.filename} uses unsupported compression method " f"{member.compress_type}" ) + raise ImportZipError(msg) diff --git a/kicad_lib_manager/commands/import_zip/command.py b/kicad_lib_manager/commands/import_zip/command.py index 510cb38..6e8d7ae 100644 --- a/kicad_lib_manager/commands/import_zip/command.py +++ b/kicad_lib_manager/commands/import_zip/command.py @@ -10,18 +10,21 @@ from typing import Annotated import typer -from rich.console import Console -from ...services.config_service import Config -from .errors import ImportFailure -from .importer import ImportTarget, ZipResult, import_zip_file -from .kicad_cli import Upgrader, detect_kicad_cli -from .target import build_target +from kicad_lib_manager.commands.import_zip.errors import ImportZipError +from kicad_lib_manager.commands.import_zip.importer import ( + ImportTarget, + ZipResult, + import_zip_file, +) +from kicad_lib_manager.commands.import_zip.kicad_cli import Upgrader, detect_kicad_cli +from kicad_lib_manager.commands.import_zip.target import LibrarySelection, build_target +from kicad_lib_manager.services.config_service import Config +from kicad_lib_manager.utils.console import get_console -console = Console() - -def import_zip( +def import_zip( # noqa: PLR0913 -- each CLI option is a Typer parameter + *, zip_files: Annotated[ list[Path], typer.Argument( @@ -41,7 +44,10 @@ def import_zip( typer.Option( "--symbol-lib", "-s", - help="Symbol library to add to, e.g. MyLib_IC (required if there are several)", + help=( + "Symbol library to add to, e.g. MyLib_IC " + "(required if there are several)" + ), ), ] = None, footprint_lib: Annotated[ @@ -49,7 +55,10 @@ def import_zip( typer.Option( "--footprint-lib", "-f", - help="Footprint library to add to, e.g. MyLib_QFN (required if there are several)", + help=( + "Footprint library to add to, e.g. MyLib_QFN " + "(required if there are several)" + ), ), ] = None, kicad_cli_path: Annotated[ @@ -65,7 +74,7 @@ def import_zip( ), ] = False, ) -> None: - """Import SamacSys/Mouser/UltraLibrarian/SnapMagic KiCad ZIP(s) into the configured library. + """Import vendor KiCad ZIPs into the configured library. Each ZIP should be a standard SamacSys multi-EDA archive (as downloaded from Mouser or component search) or an UltraLibrarian KiCad export. @@ -75,10 +84,13 @@ def import_zip( """ upgrader = Upgrader(_resolve_kicad_cli(kicad_cli_path)) target = build_target( - Config(), library, symbol_lib, footprint_lib, upgrader, dry_run + Config(), + LibrarySelection(library, symbol_lib, footprint_lib), + upgrader, + dry_run=dry_run, ) if dry_run: - console.print("[yellow]Dry run - no changes will be made[/yellow]") + get_console().print("[yellow]Dry run - no changes will be made[/yellow]") totals = ZipResult() failed: list[str] = [] @@ -89,22 +101,22 @@ def import_zip( else: totals.extend(result) - _print_summary(totals, dry_run) + _print_summary(totals, dry_run=dry_run) if failed: - console.print(f"[red]Failed to import: {', '.join(failed)}[/red]") + get_console().print(f"[red]Failed to import: {', '.join(failed)}[/red]") raise typer.Exit(1) def _resolve_kicad_cli(path: Path | None) -> Path | None: - """kicad-cli to upgrade with: path when given (it must exist), else a detected one.""" + """Return path, which must exist, else a detected kicad-cli; None if none.""" if path is not None and not path.exists(): - console.print(f"[red]kicad-cli not found at: {path}[/red]") + get_console().print(f"[red]kicad-cli not found at: {path}[/red]") raise typer.Exit(1) kicad_cli = path or detect_kicad_cli() if kicad_cli: - console.print(f"[dim]kicad-cli: {kicad_cli}[/dim]") + get_console().print(f"[dim]kicad-cli: {kicad_cli}[/dim]") else: - console.print("[dim]kicad-cli not found - format upgrade skipped[/dim]") + get_console().print("[dim]kicad-cli not found - format upgrade skipped[/dim]") return kicad_cli @@ -114,30 +126,33 @@ def _import_one(zip_path: Path, target: ImportTarget) -> ZipResult | None: A failed ZIP does not stop the run; the caller imports the rest. """ if not zip_path.exists(): - console.print(f"[red]Skipping {zip_path.name}: file not found[/red]") + get_console().print(f"[red]Skipping {zip_path.name}: file not found[/red]") return None - console.print(f"\n[cyan]Importing {zip_path.name}[/cyan]") + get_console().print(f"\n[cyan]Importing {zip_path.name}[/cyan]") try: return import_zip_file(zip_path, target) - except (ImportFailure, OSError) as exc: - console.print(f" error: {zip_path.name}: {exc}", style="red", markup=False) + except (ImportZipError, OSError) as exc: + get_console().print( + f" error: {zip_path.name}: {exc}", style="red", markup=False + ) return None -def _print_summary(totals: ZipResult, dry_run: bool) -> None: - console.print("\n[bold]Summary:[/bold]") - console.print(f" Symbols added: {len(totals.symbols)}") - console.print(f" Footprints added: {len(totals.footprints)}") - console.print(f" 3D models added: {len(totals.models)}") +def _print_summary(totals: ZipResult, *, dry_run: bool) -> None: + get_console().print("\n[bold]Summary:[/bold]") + get_console().print(f" Symbols added: {len(totals.symbols)}") + get_console().print(f" Footprints added: {len(totals.footprints)}") + get_console().print(f" 3D models added: {len(totals.models)}") if dry_run: - console.print( + get_console().print( "\n[dim]Dry run complete - run without --dry-run to apply changes.[/dim]" ) elif totals.symbols or totals.footprints or totals.models: - console.print("\n[green]Import complete.[/green]") - console.print( - "[dim]Note: If this library is not yet configured in KiCad, run 'kilm setup' to register it.[/dim]" + get_console().print("\n[green]Import complete.[/green]") + get_console().print( + "[dim]Note: If this library is not yet configured in KiCad, " + "run 'kilm setup' to register it.[/dim]" ) else: - console.print("\n[dim]Nothing new added.[/dim]") + get_console().print("\n[dim]Nothing new added.[/dim]") diff --git a/kicad_lib_manager/commands/import_zip/errors.py b/kicad_lib_manager/commands/import_zip/errors.py index ee72f35..eeb4925 100644 --- a/kicad_lib_manager/commands/import_zip/errors.py +++ b/kicad_lib_manager/commands/import_zip/errors.py @@ -1,7 +1,7 @@ """Errors of the import command.""" -class ImportFailure(Exception): +class ImportZipError(Exception): """A ZIP that cannot be imported. The command reports the message, skips the ZIP, imports the remaining diff --git a/kicad_lib_manager/commands/import_zip/formats.py b/kicad_lib_manager/commands/import_zip/formats.py index 00b21ec..d5933f0 100644 --- a/kicad_lib_manager/commands/import_zip/formats.py +++ b/kicad_lib_manager/commands/import_zip/formats.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Literal -from .errors import ImportFailure +from kicad_lib_manager.commands.import_zip.errors import ImportZipError # KiCad stamps s-expression files with "(version YYYYMMDD)". A KiCad release # refuses files newer than the format it writes, so an import must not raise @@ -43,7 +43,7 @@ def require_compatible( library: Path, kind: Literal["sym", "fp"], ) -> None: - """Raise ImportFailure when text's format is newer than limit. + """Raise ImportZipError when text's format is newer than limit. limit is the format of the target library (None: no limit). Merging a newer symbol under the library's older header makes the whole library @@ -54,9 +54,10 @@ def require_compatible( version = format_version(text) if limit is None or version is None or version <= limit: return - raise ImportFailure( + msg = ( f"{name} uses format {version}, newer than {library.name} ({limit}), " f"which KiCad releases that read the library cannot open. If every " f"KiCad using the library reads format {version}, run 'kicad-cli " f"{kind} upgrade {library}' and import again" ) + raise ImportZipError(msg) diff --git a/kicad_lib_manager/commands/import_zip/importer.py b/kicad_lib_manager/commands/import_zip/importer.py index a1b6da5..982d65e 100644 --- a/kicad_lib_manager/commands/import_zip/importer.py +++ b/kicad_lib_manager/commands/import_zip/importer.py @@ -6,17 +6,14 @@ from dataclasses import dataclass, field from pathlib import Path -from rich.console import Console - -from ...utils.backup import create_backup -from ...utils.file_ops import write_atomically -from .archive import extract_part_files, is_model -from .errors import ImportFailure -from .formats import require_compatible -from .kicad_cli import Upgrader -from .symbols import SymbolMerge, merge_symbols - -console = Console() +from kicad_lib_manager.commands.import_zip.archive import extract_part_files, is_model +from kicad_lib_manager.commands.import_zip.errors import ImportZipError +from kicad_lib_manager.commands.import_zip.formats import require_compatible +from kicad_lib_manager.commands.import_zip.kicad_cli import Upgrader +from kicad_lib_manager.commands.import_zip.symbols import SymbolMerge, merge_symbols +from kicad_lib_manager.utils.backup import create_backup +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.file_ops import write_atomically @dataclass(frozen=True) @@ -28,6 +25,7 @@ class ModelRef: @property def path(self) -> str: + """The reference footprints use for this model.""" return f"{self.prefix}/{self.name}" @@ -73,6 +71,7 @@ class ZipResult: models: list[str] = field(default_factory=list) def extend(self, other: "ZipResult") -> None: + """Add what other added to this result.""" self.symbols.extend(other.symbols) self.footprints.extend(other.footprints) self.models.extend(other.models) @@ -114,7 +113,7 @@ def import_zip_file(zip_path: Path, target: ImportTarget) -> ZipResult: before its first change in the run. Raises: - ImportFailure: The ZIP is unreadable or holds files the library + ImportZipError: The ZIP is unreadable or holds files the library cannot take. OSError: A write failed. Files written before it stay, and later ZIPs and re-runs skip them. @@ -126,7 +125,7 @@ def import_zip_file(zip_path: Path, target: ImportTarget) -> ZipResult: _plan_footprints(files, target, plan) _plan_symbols(files, target, plan) for line in plan.lines: - console.print(line, markup=False, highlight=False) + get_console().print(line, markup=False, highlight=False) _apply(plan, target) return plan.result() @@ -160,9 +159,8 @@ def _read_utf8(path: Path) -> str: try: return path.read_bytes().decode("utf-8") except UnicodeDecodeError as exc: - raise ImportFailure( - f"{path.name} is not valid UTF-8 ({exc.reason} at byte {exc.start})" - ) from exc + msg = f"{path.name} is not valid UTF-8 ({exc.reason} at byte {exc.start})" + raise ImportZipError(msg) from exc def _read_vendor_text(path: Path) -> str: @@ -247,6 +245,8 @@ def _apply(plan: _Plan, target: ImportTarget) -> None: if not target.dry_run: if target.backup is None: target.backup = create_backup(target.sym_lib) - console.print(f" Backup: {target.backup}", style="dim", markup=False) + get_console().print( + f" Backup: {target.backup}", style="dim", markup=False + ) write_atomically(target.sym_lib, plan.symbols.text.encode("utf-8")) target.symbols.update(plan.symbols.added) diff --git a/kicad_lib_manager/commands/import_zip/kicad_cli.py b/kicad_lib_manager/commands/import_zip/kicad_cli.py index 5f584bc..2b1cdaa 100644 --- a/kicad_lib_manager/commands/import_zip/kicad_cli.py +++ b/kicad_lib_manager/commands/import_zip/kicad_cli.py @@ -1,7 +1,6 @@ """Finding kicad-cli and upgrading vendor files with it.""" import os -import re import shutil import subprocess import sys @@ -9,11 +8,9 @@ from dataclasses import dataclass from pathlib import Path -from rich.console import Console - -from .formats import format_version - -console = Console() +from kicad_lib_manager.commands.import_zip.formats import format_version +from kicad_lib_manager.services.kicad_config import version_key +from kicad_lib_manager.utils.console import get_console _KICAD_CLI_CANDIDATES: tuple[Path, ...] = ( Path.home() / "AppImages" / "kicad.appimage", @@ -33,11 +30,9 @@ def _windows_kicad_cli_candidates() -> list[Path]: """ program_files = Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) found = program_files.glob("KiCad/*/bin/kicad-cli.exe") - - def version_key(path: Path) -> list[int]: - return [int(n) for n in re.findall(r"\d+", path.parent.parent.name)] - - return sorted(found, key=version_key, reverse=True) + return sorted( + found, key=lambda path: version_key(path.parent.parent.name), reverse=True + ) def detect_kicad_cli() -> Path | None: @@ -115,17 +110,19 @@ def _run(kicad_cli: Path, label: str, *args: str) -> bool: """Run kicad-cli with args; warn about label and return False on failure.""" cmd = _build_kicad_cli_cmd(kicad_cli, *args) try: - subprocess.run( + subprocess.run( # noqa: S603 -- cmd is kicad-cli with fixed upgrade arguments and temp paths cmd, capture_output=True, text=True, check=True, timeout=_TIMEOUT_SECONDS ) except subprocess.TimeoutExpired: - console.print( - f"[yellow] warn: kicad-cli {args[0]} upgrade timed out for {label}, skipping[/yellow]" + get_console().print( + f"[yellow] warn: kicad-cli {args[0]} upgrade timed out for {label}, " + "skipping[/yellow]" ) return False except subprocess.CalledProcessError as exc: - console.print( - f"[yellow] warn: kicad-cli {args[0]} upgrade failed for {label}: {exc.stderr.strip()}[/yellow]" + get_console().print( + f"[yellow] warn: kicad-cli {args[0]} upgrade failed for {label}: " + f"{exc.stderr.strip()}[/yellow]" ) return False return True @@ -136,7 +133,7 @@ def _within_limit(text: str, limit: int | None, suffix: str) -> bool: version = format_version(text) if limit is None or version is None or version <= limit: return True - console.print( + get_console().print( f"[dim] kicad-cli writes format {version}, newer than {limit} in the " f"target library; keeping vendor {suffix} formats[/dim]" ) diff --git a/kicad_lib_manager/commands/import_zip/symbols.py b/kicad_lib_manager/commands/import_zip/symbols.py index 690b22e..546e71a 100644 --- a/kicad_lib_manager/commands/import_zip/symbols.py +++ b/kicad_lib_manager/commands/import_zip/symbols.py @@ -5,7 +5,7 @@ from collections.abc import Set as AbstractSet from dataclasses import dataclass -from .errors import ImportFailure +from kicad_lib_manager.commands.import_zip.errors import ImportZipError def extract_symbol_blocks(text: str) -> list[str]: @@ -50,7 +50,7 @@ def extract_symbol_blocks(text: str) -> list[str]: def symbol_name(block: str) -> str: """Name of a `(symbol "...")` block, "" when it has none.""" m = re.match(r'\(symbol "([^"]+)"', block) - return m.group(1) if m else "" + return str(m.group(1)) if m else "" def fix_footprint_ref( @@ -66,7 +66,7 @@ def fix_footprint_ref( def _replace(m: re.Match[str]) -> str: val = m.group(1) name = val.split(":", 1)[1] if ":" in val else val - if fp_refs and name in fp_refs: + if fp_refs is not None and name in fp_refs: val = fp_refs[name] elif ":" not in val: val = f"{default_lib}:{val}" @@ -100,10 +100,11 @@ def merge_symbols( with fix_footprint_ref. Raises: - ImportFailure: library is not a KiCad symbol library. + ImportZipError: library is not a KiCad symbol library. """ if not library.lstrip("\ufeff \t\r\n").startswith("(kicad_symbol_lib"): - raise ImportFailure("target is not a KiCad symbol library") + msg = "target is not a KiCad symbol library" + raise ImportZipError(msg) newline = "\r\n" if "\r\n" in library else "\n" present = {symbol_name(b) for b in extract_symbol_blocks(library)} | set(known) @@ -117,8 +118,8 @@ def merge_symbols( continue present.add(name) added.append(name) - block = fix_footprint_ref(block, default_fp_lib, fp_refs) - new_blocks.append(block.replace("\n", newline)) + fixed = fix_footprint_ref(block, default_fp_lib, fp_refs) + new_blocks.append(fixed.replace("\n", newline)) if not new_blocks: return SymbolMerge(library, added, skipped) diff --git a/kicad_lib_manager/commands/import_zip/target.py b/kicad_lib_manager/commands/import_zip/target.py index 4c5a78b..4523536 100644 --- a/kicad_lib_manager/commands/import_zip/target.py +++ b/kicad_lib_manager/commands/import_zip/target.py @@ -1,54 +1,69 @@ """Resolving the libraries an import writes to from the KiLM configuration.""" +from dataclasses import dataclass from pathlib import Path import typer -from rich.console import Console -from ...services.config_service import Config, LibraryDict -from ...utils.metadata import read_cloud_metadata, read_github_metadata -from .formats import file_format_version, footprint_lib_version -from .importer import ImportTarget, ModelRef -from .kicad_cli import Upgrader +from kicad_lib_manager.commands.import_zip.formats import ( + file_format_version, + footprint_lib_version, +) +from kicad_lib_manager.commands.import_zip.importer import ImportTarget, ModelRef +from kicad_lib_manager.commands.import_zip.kicad_cli import Upgrader +from kicad_lib_manager.services.config_service import Config, LibraryDict +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.metadata import read_cloud_metadata, read_github_metadata -console = Console() + +@dataclass(frozen=True) +class LibrarySelection: + """The libraries the user asked to import into; None leaves the choice to KiLM. + + library names the KiLM library (default: the current one); symbol_lib + and footprint_lib pick among several symbol and footprint libraries in it. + """ + + library: str | None = None + symbol_lib: str | None = None + footprint_lib: str | None = None def build_target( config: Config, - library: str | None, - symbol_lib: str | None, - footprint_lib: str | None, + selection: LibrarySelection, upgrader: Upgrader, + *, dry_run: bool, ) -> ImportTarget: """Resolve the symbol library, footprint library, and models dir to import into. - library names the KiLM library (default: the current one); symbol_lib - and footprint_lib pick among several in it. Reports the problem and - raises typer.Exit(1) when there is no usable target. + Reports the problem and raises typer.Exit(1) when there is no usable + target. """ - lib_path = _library_path(config, library) + lib_path = _library_path(config, selection.library) sym_candidates = sorted((lib_path / "symbols").glob("*.kicad_sym")) fp_candidates = sorted( d for d in (lib_path / "footprints").glob("*.pretty") if d.is_dir() ) if not sym_candidates: - console.print( + get_console().print( f"[red]No .kicad_sym file in {lib_path / 'symbols'}. Create a symbol " "library there first (KiCad Symbol Editor: File > New Library).[/red]" ) raise typer.Exit(1) if not fp_candidates: - console.print( + get_console().print( f"[red]No .pretty directory in {lib_path / 'footprints'}. Create a " "footprint library there first (KiCad Footprint Editor: File > New " "Library).[/red]" ) raise typer.Exit(1) - sym_lib = _pick_lib(sym_candidates, symbol_lib, "symbol", "--symbol-lib") - fp_dir = _pick_lib(fp_candidates, footprint_lib, "footprint", "--footprint-lib") + sym_lib = _pick_lib(sym_candidates, selection.symbol_lib, "symbol", "--symbol-lib") + fp_dir = _pick_lib( + fp_candidates, selection.footprint_lib, "footprint", "--footprint-lib" + ) default_models_name = ( sym_candidates[0].stem if len(sym_candidates) == 1 else lib_path.name ) @@ -65,12 +80,14 @@ def build_target( if not all( p.startswith("${") for p in [model_prefix, *(m.prefix for m in models.values())] ): - console.print( + get_console().print( "[yellow]3D model paths will be absolute to this machine. Set env_var " "in kilm.yaml (kilm init) or register a 3D library (kilm add-3d) to " "share the library.[/yellow]" ) - console.print(f"[dim]Target: {sym_lib.stem} / {fp_dir.stem} / {model_prefix}[/dim]") + get_console().print( + f"[dim]Target: {sym_lib.stem} / {fp_dir.stem} / {model_prefix}[/dim]" + ) return ImportTarget( sym_lib=sym_lib, @@ -92,7 +109,9 @@ def _library_path(config: Config, library: str | None) -> Path: """Path of the named github library, else of the current one.""" github_libs = config.get_libraries(library_type="github") if not github_libs: - console.print("[red]No github library configured. Run 'kilm init' first.[/red]") + get_console().print( + "[red]No github library configured. Run 'kilm init' first.[/red]" + ) raise typer.Exit(1) if library is None: @@ -107,12 +126,12 @@ def _library_path(config: Config, library: str | None) -> Path: (lib for lib in github_libs if lib.get("name") == library), None ) if target_lib is None: - console.print(f"[red]Library '{library}' not found in config.[/red]") + get_console().print(f"[red]Library '{library}' not found in config.[/red]") raise typer.Exit(1) lib_path = Path(target_lib["path"]) if not lib_path.exists(): - console.print(f"[red]Library path does not exist: {lib_path}[/red]") + get_console().print(f"[red]Library path does not exist: {lib_path}[/red]") raise typer.Exit(1) return lib_path @@ -124,12 +143,15 @@ def _pick_lib(candidates: list[Path], name: str | None, kind: str, option: str) for c in candidates: if c.stem == name: return c - console.print(f"[red]No {kind} library '{name}'. Available: {available}[/red]") + get_console().print( + f"[red]No {kind} library '{name}'. Available: {available}[/red]" + ) raise typer.Exit(1) if len(candidates) == 1: return candidates[0] - console.print( - f"[red]Several {kind} libraries found, choose one with {option}: {available}[/red]" + get_console().print( + f"[red]Several {kind} libraries found, choose one with {option}: " + f"{available}[/red]" ) raise typer.Exit(1) @@ -173,7 +195,7 @@ def _resolve_models( models_dir = existing[0] elif existing: models_dir = existing[0] - console.print( + get_console().print( f"[yellow]Several *.3dshapes dirs in {lib_path}; new models go to " f"{models_dir.name} (register the right one with 'kilm add-3d')[/yellow]" ) @@ -191,9 +213,11 @@ def _existing_models( of the library, so an import reuses a model wherever it already lives. """ dirs = [(models_dir, model_prefix)] - for d in sorted(lib_path.glob("*.3dshapes")): - if d.is_dir() and d.resolve() != models_dir.resolve(): - dirs.append((d, _dir_prefix(d, lib_env_var))) + dirs.extend( + (d, _dir_prefix(d, lib_env_var)) + for d in sorted(lib_path.glob("*.3dshapes")) + if d.is_dir() and d.resolve() != models_dir.resolve() + ) models: dict[str, ModelRef] = {} for d, prefix in dirs: if d.is_dir(): diff --git a/kicad_lib_manager/commands/init/__init__.py b/kicad_lib_manager/commands/init/__init__.py index 3ea0b37..f5ccd28 100644 --- a/kicad_lib_manager/commands/init/__init__.py +++ b/kicad_lib_manager/commands/init/__init__.py @@ -1,3 +1,5 @@ -from .command import init +"""The ``init`` command: register a symbol and footprint library.""" + +from kicad_lib_manager.commands.init.command import init __all__ = ["init"] diff --git a/kicad_lib_manager/commands/init/command.py b/kicad_lib_manager/commands/init/command.py index 1e93fc5..e02c284 100644 --- a/kicad_lib_manager/commands/init/command.py +++ b/kicad_lib_manager/commands/init/command.py @@ -1,23 +1,97 @@ -""" -Init command implementation for KiCad Library Manager (Typer version). -Initializes the current directory as a KiCad library directory (symbols, footprints, templates). +"""The ``init`` command: register the current directory as a KiCad library. + +The directory gets ``symbols``, ``footprints``, and ``templates`` folders, a +metadata file, and a ``library_descriptions.yaml`` template, and is recorded +in the KiLM configuration as a GitHub library. """ from pathlib import Path from typing import Annotated import typer -from rich.console import Console from rich.panel import Panel -from rich.prompt import Confirm -from ...services.config_service import Config -from ...services.library_service import LibraryService +from kicad_lib_manager.services.config_service import Config +from kicad_lib_manager.services.library_service import LibraryService +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.metadata import LIBRARY_FOLDERS + +LIBRARY_DESCRIPTIONS_FILE = "library_descriptions.yaml" +LIBRARY_DESCRIPTIONS_TEMPLATE = """\ +# Library Descriptions for KiCad +# Format: +# library_name: "Description text" +# +# Example: +# Symbols_library: "Sample symbol library description" + +# Symbol library descriptions +symbols: + Symbols_library: "Sample symbol library description" + +# Footprint library descriptions +footprints: + Footprints_library: "Sample footprint library description" +""" + -console = Console() +def _sort_folders(present_before: set[str]) -> tuple[list[str], list[str]]: + """Split the library folders into those that existed and those just created. + + ``present_before`` names the directory entries from before initialization. + """ + existing = [folder for folder in LIBRARY_FOLDERS if folder in present_before] + created = [folder for folder in LIBRARY_FOLDERS if folder not in present_before] + return existing, created -def init( +def _write_descriptions_template(directory: Path) -> None: + """Create ``library_descriptions.yaml`` unless the library already has one.""" + path = directory / LIBRARY_DESCRIPTIONS_FILE + if path.exists(): + return + console = get_console() + try: + path.write_text(LIBRARY_DESCRIPTIONS_TEMPLATE, encoding="utf-8") + except OSError as e: + console.print( + "[yellow]Warning: Could not create library_descriptions.yaml " + f"file: {e}[/yellow]" + ) + return + console.print("[green]Created library_descriptions.yaml template file.[/green]") + + +def _report_folders(existing: list[str], created: list[str]) -> None: + """Print which library folders already existed and which were created.""" + console = get_console() + if existing: + console.print(f"[blue]Found existing folders:[/blue] {', '.join(existing)}") + if created: + console.print(f"[green]Created new folders:[/green] {', '.join(created)}") + + +def _success_message( + name: str, directory: Path, env_var: object, *, set_current: bool +) -> str: + """Return the body of the panel shown after a successful init.""" + message = ( + f"[bold green]Library '{name}' initialized successfully![/bold green]\n\n" + "[cyan]Type:[/cyan] GitHub library (symbols, footprints, templates)\n" + f"[cyan]Path:[/cyan] {directory}\n" + ) + if env_var: + message += f"[cyan]Environment Variable:[/cyan] {env_var}\n" + if set_current: + message += ( + "\n[yellow]This is now your current active library.[/yellow]\n" + "[dim]KiLM will use this library for all commands by default.[/dim]" + ) + return message + + +def init( # noqa: PLR0913 -- each CLI option is a Typer parameter + *, name: Annotated[ str | None, typer.Option( @@ -61,24 +135,16 @@ def init( ), ] = False, ) -> None: - """ - Initialize the current directory as a KiCad library collection. - - This command sets up the current directory as a KiCad library containing - symbols, footprints, and templates. It creates the required folders if they - don't exist and registers the library in the local configuration. - - [bold]Features:[/bold] - • Creates symbol, footprint, and template directories - • Generates metadata file with library information - • Assigns unique environment variable for KiCad integration - • Registers library in KiLM configuration + """Make the current directory a symbol/footprint library and register it. - [bold]Note:[/bold] This is intended for GitHub-based libraries containing - symbols and footprints, not for 3D model libraries. + Creates the library folders, metadata, and descriptions template (see + the module docstring), records the directory in the KiLM configuration + as a GitHub library, and makes it the current library unless + --no-set-current is given. Exits 1 when the library cannot be written + or registered. 3D model libraries use ``add-3d`` instead. """ current_dir = Path.cwd().resolve() - + console = get_console() console.print( Panel( f"[bold blue]Initializing KiCad library[/bold blue]\n" @@ -88,10 +154,10 @@ def init( ) ) - # Use library service to initialize - library_service = LibraryService() + # The service creates the library folders, so note what exists beforehand. + present_before = {entry.name for entry in current_dir.iterdir()} try: - metadata = library_service.initialize_library( + metadata = LibraryService().initialize_library( directory=current_dir, name=name, description=description, @@ -99,123 +165,37 @@ def init( force=force, no_env_var=no_env_var, ) - - library_name = metadata.get("name") - library_env_var = metadata.get("env_var") - - # Get the directory status - existing_folders = [] - created_folders = [] - capabilities = metadata.get("capabilities", {}) - - for folder_type, exists in capabilities.items(): - if exists: - folder_path = current_dir / folder_type - if folder_path.exists(): - existing_folders.append(folder_type) - else: - created_folders.append(folder_type) - except Exception as e: console.print(f"[red]Error initializing library: {e}[/red]") raise typer.Exit(1) from e - # Create empty library_descriptions.yaml if it doesn't exist - library_descriptions_file = current_dir / "library_descriptions.yaml" - if not library_descriptions_file.exists(): - try: - # Create a template with comments and examples - template_content = """# Library Descriptions for KiCad -# Format: -# library_name: "Description text" -# -# Example: -# Symbols_library: "Sample symbol library description" - -# Symbol library descriptions -symbols: - Symbols_library: "Sample symbol library description" - -# Footprint library descriptions -footprints: - Footprints_library: "Sample footprint library description" -""" - with library_descriptions_file.open("w", encoding="utf-8") as f: - f.write(template_content) - console.print( - "[green]Created library_descriptions.yaml template file.[/green]" - ) - except Exception as e: - console.print( - f"[yellow]Warning: Could not create library_descriptions.yaml file: {e}[/yellow]" - ) - - # Metadata is already updated by the service - - # Report on folder status - if existing_folders: - console.print( - f"[blue]Found existing folders:[/blue] {', '.join(existing_folders)}" - ) - if created_folders: - console.print( - f"[green]Created new folders:[/green] {', '.join(created_folders)}" - ) - - # Verify if this looks like a KiCad library - if not created_folders and not existing_folders: - console.print( - "[yellow]Warning: No library folders were found or created.[/yellow]" - ) - if not Confirm.ask("Continue anyway?", default=True): - console.print("[yellow]Initialization cancelled.[/yellow]") - raise typer.Exit(0) + _write_descriptions_template(current_dir) + _report_folders(*_sort_folders(present_before)) - # Update the configuration + library_name = str(metadata.get("name") or current_dir.name) try: config = Config() - # Record as a GitHub library (symbols + footprints) - safe_library_name = str(library_name or current_dir.name) - config.add_library(safe_library_name, str(current_dir), "github") - + config.add_library(library_name, str(current_dir), "github") if set_current: config.set_current_library(str(current_dir)) - - # Create success panel - success_content = f"[bold green]Library '{safe_library_name}' initialized successfully![/bold green]\n\n" - success_content += ( - "[cyan]Type:[/cyan] GitHub library (symbols, footprints, templates)\n" - ) - success_content += f"[cyan]Path:[/cyan] {current_dir}\n" - - if library_env_var: - success_content += f"[cyan]Environment Variable:[/cyan] {library_env_var}\n" - - if set_current: - success_content += ( - "\n[yellow]This is now your current active library.[/yellow]\n" - ) - success_content += ( - "[dim]KiLM will use this library for all commands by default.[/dim]" - ) - - console.print( - Panel( - success_content, - title="✅ Initialization Complete", - border_style="green", - ) - ) - - # Add a hint for adding 3D models - console.print("\n[bold]Next Steps:[/bold]") - console.print( - "To add a 3D models directory (typically stored in the cloud), use:" - ) - console.print( - "[dim] kilm add-3d --name my-3d-models --directory /path/to/3d/models[/dim]" - ) - except Exception as e: console.print(f"[red]Error initializing library: {e}[/red]") raise typer.Exit(1) from e + + console.print( + Panel( + _success_message( + library_name, + current_dir, + metadata.get("env_var"), + set_current=set_current, + ), + title="Initialization Complete", + border_style="green", + ) + ) + console.print("\n[bold]Next Steps:[/bold]") + console.print("To add a 3D models directory (typically stored in the cloud), use:") + console.print( + "[dim] kilm add-3d --name my-3d-models --directory /path/to/3d/models[/dim]" + ) diff --git a/kicad_lib_manager/commands/list_libraries/__init__.py b/kicad_lib_manager/commands/list_libraries/__init__.py index df7abab..b98c0b4 100644 --- a/kicad_lib_manager/commands/list_libraries/__init__.py +++ b/kicad_lib_manager/commands/list_libraries/__init__.py @@ -1,3 +1,5 @@ -from .command import list_cmd +"""The ``list`` command: show the libraries in a library directory.""" + +from kicad_lib_manager.commands.list_libraries.command import list_cmd __all__ = ["list_cmd"] diff --git a/kicad_lib_manager/commands/list_libraries/command.py b/kicad_lib_manager/commands/list_libraries/command.py index 4421337..8b8a2d9 100644 --- a/kicad_lib_manager/commands/list_libraries/command.py +++ b/kicad_lib_manager/commands/list_libraries/command.py @@ -1,101 +1,74 @@ -""" -List command implementation for KiCad Library Manager (Typer version). -""" +"""The ``list`` command: show the libraries in a library directory.""" from pathlib import Path from typing import Annotated import typer -from rich.console import Console from rich.table import Table -from ...services.library_service import LibraryService -from ...utils.env_vars import expand_user_path, find_environment_variables +from kicad_lib_manager.commands.shared import resolve_kicad_lib_dir +from kicad_lib_manager.services.library_service import LibraryService +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import SYMBOL_DIR_VAR -console = Console() + +def _print_library_table( + kind: str, names: list[str], extension: str, style: str +) -> None: + """Print the ``kind`` libraries in ``names``, or a notice when there are none.""" + console = get_console() + if not names: + console.print(f"[yellow]No {kind} libraries found[/yellow]") + return + table = Table( + title=f"Available {kind.capitalize()} Libraries", + show_header=True, + header_style=f"bold {style}", + ) + table.add_column("Library Name", style=style, no_wrap=True) + table.add_column("Type", style="blue") + for name in sorted(names): + table.add_row(name, extension) + console.print(table) def list_cmd( + *, kicad_lib_dir: Annotated[ str | None, typer.Option( "--kicad-lib-dir", - help="KiCad library directory (uses KICAD_USER_LIB env var if not specified)", - envvar="KICAD_USER_LIB", + help=( + "KiCad library directory (uses KICAD_USER_LIB env var if not specified)" + ), + envvar=SYMBOL_DIR_VAR, ), ] = None, ) -> None: - """ - List available libraries in the specified directory. + """List available libraries in the specified directory. This command scans the KiCad library directory for symbol (.kicad_sym) and footprint (.pretty) libraries and displays them in organized tables. """ - # Find environment variables if not provided - if not kicad_lib_dir: - kicad_lib_dir = find_environment_variables("KICAD_USER_LIB") - if not kicad_lib_dir: - console.print("[red]Error: KICAD_USER_LIB not set and not provided[/red]") - raise typer.Exit(1) - - # Expand user home directory if needed - kicad_lib_dir = expand_user_path(kicad_lib_dir) - + library_dir = resolve_kicad_lib_dir(kicad_lib_dir) + console = get_console() try: - library_service = LibraryService() - symbols, footprints = library_service.list_libraries(Path(kicad_lib_dir)) - - console.print(f"[blue]Scanning library directory:[/blue] {kicad_lib_dir}\n") - - # Display symbol libraries in a table - if symbols: - symbol_table = Table( - title="Available Symbol Libraries", - show_header=True, - header_style="bold cyan", - ) - symbol_table.add_column("Library Name", style="cyan", no_wrap=True) - symbol_table.add_column("Type", style="blue") - - for symbol in sorted(symbols): - symbol_table.add_row(symbol, ".kicad_sym") - - console.print(symbol_table) - else: - console.print("[yellow]No symbol libraries found[/yellow]") - - console.print() # Empty line for spacing - - # Display footprint libraries in a table - if footprints: - footprint_table = Table( - title="Available Footprint Libraries", - show_header=True, - header_style="bold magenta", - ) - footprint_table.add_column("Library Name", style="magenta", no_wrap=True) - footprint_table.add_column("Type", style="blue") - - for footprint in sorted(footprints): - footprint_table.add_row(footprint, ".pretty") - - console.print(footprint_table) - else: - console.print("[yellow]No footprint libraries found[/yellow]") - - # Summary information - total_libs = len(symbols) + len(footprints) - if total_libs > 0: - console.print( - f"\n[green]Found {total_libs} libraries total[/green] " - f"([cyan]{len(symbols)} symbol[/cyan], " - f"[magenta]{len(footprints)} footprint[/magenta])" - ) - else: - console.print( - "[yellow]No libraries found in the specified directory[/yellow]" - ) - + symbols, footprints = LibraryService().list_libraries(Path(library_dir)) except Exception as e: console.print(f"[red]Error listing libraries: {e}[/red]") raise typer.Exit(1) from e + + console.print(f"[blue]Scanning library directory:[/blue] {library_dir}\n") + _print_library_table("symbol", symbols, ".kicad_sym", "cyan") + console.print() + _print_library_table("footprint", footprints, ".pretty", "magenta") + + total = len(symbols) + len(footprints) + if total == 0: + console.print("[yellow]No libraries found in the specified directory[/yellow]") + return + console.print( + f"\n[green]Found {total} libraries total[/green] " + f"([cyan]{len(symbols)} symbol[/cyan], " + f"[magenta]{len(footprints)} footprint[/magenta])" + ) diff --git a/kicad_lib_manager/commands/pin/__init__.py b/kicad_lib_manager/commands/pin/__init__.py index 21044cf..1d97f57 100644 --- a/kicad_lib_manager/commands/pin/__init__.py +++ b/kicad_lib_manager/commands/pin/__init__.py @@ -1,3 +1,5 @@ -from .command import pin +"""The ``pin`` command: pin libraries in KiCad's quick-access lists.""" + +from kicad_lib_manager.commands.pin.command import pin __all__ = ["pin"] diff --git a/kicad_lib_manager/commands/pin/command.py b/kicad_lib_manager/commands/pin/command.py index 8ffea1c..4b97450 100644 --- a/kicad_lib_manager/commands/pin/command.py +++ b/kicad_lib_manager/commands/pin/command.py @@ -1,33 +1,120 @@ -""" -Pin command implementation for KiCad Library Manager (Typer version). -""" +"""The ``pin`` command: pin libraries in KiCad's quick-access lists.""" from pathlib import Path from typing import Annotated import typer -from rich.console import Console from rich.panel import Panel from rich.table import Table -from ...services.kicad_service import KiCadService -from ...services.library_service import LibraryService -from ...utils.env_vars import ( - expand_user_path, - find_environment_variables, - update_pinned_libraries, -) +from kicad_lib_manager.commands.shared import resolve_kicad_lib_dir +from kicad_lib_manager.services.library_service import LibraryService +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import DEFAULT_MAX_BACKUPS, SYMBOL_DIR_VAR +from kicad_lib_manager.utils.env_vars import update_pinned_libraries -console = Console() + +def _find_kicad_config(*, verbose: bool) -> Path: + """Return KiCad's configuration directory; exit when it cannot be found.""" + console = get_console() + try: + kicad_config = LibraryService.find_kicad_config() + except Exception as e: + console.print(f"[red]Error finding KiCad configuration: {e}[/red]") + raise typer.Exit(1) from e + if verbose: + console.print(f"[blue]Found KiCad configuration at:[/blue] {kicad_config}") + return kicad_config + + +def _all_libraries(library_dir: Path, *, verbose: bool) -> tuple[list[str], list[str]]: + """Return every symbol and footprint library in ``library_dir``.""" + try: + symbols, footprints = LibraryService().list_libraries(library_dir) + except Exception as e: + get_console().print(f"[red]Error listing libraries: {e}[/red]") + raise typer.Exit(1) from e + if verbose: + get_console().print( + f"[green]Found {len(symbols)} symbol libraries and " + f"{len(footprints)} footprint libraries[/green]" + ) + return symbols, footprints -def pin( +def _warn_about_missing( + library_dir: Path, symbols: list[str], footprints: list[str] +) -> None: + """Warn about selected libraries that ``library_dir`` does not contain. + + Pinning goes ahead regardless, since a library may be configured in KiCad + without living in the library directory. + """ + console = get_console() + try: + available_symbols, available_footprints = LibraryService().list_libraries( + library_dir + ) + except OSError as e: + console.print(f"[yellow]Error validating libraries: {e}[/yellow]") + return + for symbol in symbols: + if symbol not in available_symbols: + console.print( + f"[yellow]Warning: Symbol library '{symbol}' not found[/yellow]" + ) + for footprint in footprints: + if footprint not in available_footprints: + console.print( + f"[yellow]Warning: Footprint library '{footprint}' not found[/yellow]" + ) + + +def _report( + symbols: list[str], footprints: list[str], *, changed: bool, dry_run: bool +) -> None: + """Print the outcome of a pin request.""" + console = get_console() + counts = f"{len(symbols)} symbol and {len(footprints)} footprint libraries" + if not changed: + console.print( + "[blue]No changes needed, libraries already pinned in KiCad[/blue]" + ) + elif dry_run: + console.print(f"[yellow]Would pin {counts} in KiCad[/yellow]") + else: + console.print( + Panel( + f"[green]Pinned {counts} in KiCad[/green]\n\n" + "[blue]• Created backup of kicad_common.json[/blue]\n" + "[yellow]• Restart KiCad for changes to take effect[/yellow]", + title="Libraries Pinned", + border_style="green", + ) + ) + + +def _print_table(kind: str, names: list[str], style: str) -> None: + """Print the pinned ``kind`` libraries in ``names`` as a table.""" + if not names: + return + table = Table(title=f"Pinned {kind} Libraries") + table.add_column("Library", style=style) + for name in sorted(names): + table.add_row(name) + get_console().print(table) + + +def pin( # noqa: PLR0913 -- each CLI option is a Typer parameter + *, kicad_lib_dir: Annotated[ str | None, typer.Option( "--kicad-lib-dir", - envvar="KICAD_USER_LIB", - help="KiCad library directory (uses KICAD_USER_LIB env var if not specified)", + envvar=SYMBOL_DIR_VAR, + help=( + "KiCad library directory (uses KICAD_USER_LIB env var if not specified)" + ), ), ] = None, symbols: Annotated[ @@ -66,7 +153,7 @@ def pin( "--max-backups", help="Maximum number of backups to keep", ), - ] = 5, + ] = DEFAULT_MAX_BACKUPS, verbose: Annotated[ bool, typer.Option( @@ -76,134 +163,40 @@ def pin( ), ] = False, ) -> None: - """ - Pin libraries in KiCad for quick access. + """Pin libraries in KiCad for quick access. This command pins libraries in KiCad's interface for quick access. You can pin specific symbol and footprint libraries or all available libraries. """ - # Initialize default values for mutable types - if symbols is None: - symbols = [] - if footprints is None: - footprints = [] - - # Find environment variables if not provided - if not kicad_lib_dir: - kicad_lib_dir = find_environment_variables("KICAD_USER_LIB") - if not kicad_lib_dir: - console.print("[red]Error: KICAD_USER_LIB not set and not provided[/red]") - raise typer.Exit(1) - - # Expand user home directory if needed - kicad_lib_dir = expand_user_path(kicad_lib_dir) - + library_dir = Path(resolve_kicad_lib_dir(kicad_lib_dir)) if verbose: - console.print(f"[blue]Using KiCad library directory:[/blue] {kicad_lib_dir}") - - # Initialize services - library_service = LibraryService() - kicad_service = KiCadService() - - # Find KiCad configuration - try: - kicad_config = kicad_service.find_kicad_config_dir() - if verbose: - console.print(f"[blue]Found KiCad configuration at:[/blue] {kicad_config}") - except Exception as e: - console.print(f"[red]Error finding KiCad configuration: {e}[/red]") - raise typer.Exit(1) from e - - # If --all is specified, get all libraries from the directory - if all_libs and not symbols and not footprints: - try: - symbol_libs, footprint_libs = library_service.list_libraries( - Path(kicad_lib_dir) - ) - symbols = list(symbol_libs) - footprints = list(footprint_libs) - if verbose: - console.print( - f"[green]Found {len(symbols)} symbol libraries and {len(footprints)} footprint libraries[/green]" - ) - except Exception as e: - console.print(f"[red]Error listing libraries: {e}[/red]") - raise typer.Exit(1) from e - - # Ensure we have lists (Typer should already provide lists) - if not isinstance(symbols, list): - symbols = list(symbols) if symbols else [] - if not isinstance(footprints, list): - footprints = list(footprints) if footprints else [] - - # Validate that libraries exist - if not all_libs and (symbols or footprints): - try: - available_symbols, available_footprints = library_service.list_libraries( - Path(kicad_lib_dir) - ) + get_console().print( + f"[blue]Using KiCad library directory:[/blue] {library_dir}" + ) + kicad_config = _find_kicad_config(verbose=verbose) - # Check symbols - for symbol in symbols: - if symbol not in available_symbols: - console.print( - f"[yellow]Warning: Symbol library '{symbol}' not found[/yellow]" - ) - - # Check footprints - for footprint in footprints: - if footprint not in available_footprints: - console.print( - f"[yellow]Warning: Footprint library '{footprint}' not found[/yellow]" - ) - except Exception as e: - console.print(f"[yellow]Error validating libraries: {e}[/yellow]") - # Continue anyway, in case the libraries are configured but not in the directory + selected_symbols = symbols or [] + selected_footprints = footprints or [] + if all_libs and not selected_symbols and not selected_footprints: + selected_symbols, selected_footprints = _all_libraries( + library_dir, verbose=verbose + ) + elif not all_libs and (selected_symbols or selected_footprints): + _warn_about_missing(library_dir, selected_symbols, selected_footprints) try: - changes_needed = update_pinned_libraries( + changed = update_pinned_libraries( kicad_config, - symbol_libs=symbols, - footprint_libs=footprints, + symbol_libs=selected_symbols, + footprint_libs=selected_footprints, dry_run=dry_run, max_backups=max_backups, ) - - if changes_needed: - if dry_run: - console.print( - f"[yellow]Would pin {len(symbols)} symbol and {len(footprints)} footprint libraries in KiCad[/yellow]" - ) - else: - success_msg = f"[green]Pinned {len(symbols)} symbol and {len(footprints)} footprint libraries in KiCad[/green]" - console.print( - Panel( - f"{success_msg}\n\n" - f"[blue]• Created backup of kicad_common.json[/blue]\n" - f"[yellow]• Restart KiCad for changes to take effect[/yellow]", - title="✅ Libraries Pinned", - border_style="green", - ) - ) - else: - console.print( - "[blue]No changes needed, libraries already pinned in KiCad[/blue]" - ) - - if verbose: - if symbols: - table = Table(title="Pinned Symbol Libraries") - table.add_column("Library", style="cyan") - for symbol in sorted(symbols): - table.add_row(symbol) - console.print(table) - - if footprints: - table = Table(title="Pinned Footprint Libraries") - table.add_column("Library", style="magenta") - for footprint in sorted(footprints): - table.add_row(footprint) - console.print(table) except Exception as e: - console.print(f"[red]Error pinning libraries: {e}[/red]") + get_console().print(f"[red]Error pinning libraries: {e}[/red]") raise typer.Exit(1) from e + + _report(selected_symbols, selected_footprints, changed=changed, dry_run=dry_run) + if verbose: + _print_table("Symbol", selected_symbols, "cyan") + _print_table("Footprint", selected_footprints, "magenta") diff --git a/kicad_lib_manager/commands/setup/__init__.py b/kicad_lib_manager/commands/setup/__init__.py index 9142379..c476c74 100644 --- a/kicad_lib_manager/commands/setup/__init__.py +++ b/kicad_lib_manager/commands/setup/__init__.py @@ -1,3 +1,5 @@ -from .command import setup +"""The ``setup`` command: add the configured libraries to KiCad.""" + +from kicad_lib_manager.commands.setup.command import setup __all__ = ["setup"] diff --git a/kicad_lib_manager/commands/setup/changes.py b/kicad_lib_manager/commands/setup/changes.py new file mode 100644 index 0000000..b12043f --- /dev/null +++ b/kicad_lib_manager/commands/setup/changes.py @@ -0,0 +1,236 @@ +"""The changes ``kilm setup`` makes to the KiCad configuration.""" + +import re +from dataclasses import dataclass +from pathlib import Path + +from kicad_lib_manager.services.library_service import LibraryService +from kicad_lib_manager.utils.backup import create_backup +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import ( + DEFAULT_MAX_BACKUPS, + FOOTPRINT_TABLE_FILE, + MODELS_DIR_VAR, + SYMBOL_DIR_VAR, + SYMBOL_TABLE_FILE, +) +from kicad_lib_manager.utils.env_vars import ( + update_kicad_env_vars, + update_pinned_libraries, +) +from kicad_lib_manager.utils.file_ops import list_libraries, write_atomically + +# A path wrapped as if it were a variable: ``(uri "${/path/lib}/Lib.kicad_sym")``. +_WRAPPED_URI = re.compile(r'\(uri "\${(\/[^}]+)}\/(.*?)"\)') + + +@dataclass(frozen=True) +class ChangeOptions: + """Where and how setup changes the KiCad configuration.""" + + kicad_config: Path + dry_run: bool + max_backups: int + verbose: bool + + +@dataclass(frozen=True) +class SetupLibraries: + """The library directories setup writes to KiCad. + + Attributes: + symbol_dir: The main symbol library directory, set as KICAD_USER_LIB. + models_dir: The main 3D model directory, set as KICAD_3D_LIB. + library_vars: Each configured library's environment variable and path. + """ + + symbol_dir: str + models_dir: str | None + library_vars: dict[str, str] + + def env_vars(self) -> dict[str, str]: + """Return every environment variable setup sets in KiCad.""" + env_vars = {SYMBOL_DIR_VAR: self.symbol_dir} + if self.models_dir is not None: + env_vars[MODELS_DIR_VAR] = self.models_dir + env_vars.update(self.library_vars) + return env_vars + + def model_dirs(self) -> dict[str, str]: + """Return the 3D model directories by environment variable.""" + model_dirs = { + name: path + for name, path in self.library_vars.items() + if name.startswith("KICAD_3D_") + } + if self.models_dir is not None: + model_dirs.setdefault(MODELS_DIR_VAR, self.models_dir) + return model_dirs + + +def fix_invalid_uris( + kicad_config: Path, + *, + max_backups: int = DEFAULT_MAX_BACKUPS, + dry_run: bool = False, +) -> bool: + """Unwrap library paths written as ``${/path}`` in KiCad's library tables. + + Each table is backed up before it is changed. + + Args: + kicad_config: The KiCad configuration directory. + max_backups: The number of backups to keep per table. + dry_run: Only report whether a table needs fixing. + + Returns: + Whether a table had (or, in a dry run, has) wrapped paths. + + Raises: + OSError: A table cannot be read, backed up, or written. + """ + changes_made = False + for table in (SYMBOL_TABLE_FILE, FOOTPRINT_TABLE_FILE): + table_path = kicad_config / table + if not table_path.exists(): + continue + content = table_path.read_text(encoding="utf-8") + if _WRAPPED_URI.search(content) is None: + continue + changes_made = True + if dry_run: + continue + create_backup(table_path, max_backups) + fixed_content = _WRAPPED_URI.sub(r'(uri "\1/\2")', content) + write_atomically(table_path, fixed_content.encode("utf-8")) + return changes_made + + +def update_environment(options: ChangeOptions, env_vars: dict[str, str]) -> bool: + """Set ``env_vars`` in KiCad and report the outcome. + + Returns: + Whether variables changed (would change, in a dry run). A failure is + reported and counts as no change, so setup continues. + """ + console = get_console() + try: + changed = update_kicad_env_vars( + options.kicad_config, + env_vars, + dry_run=options.dry_run, + max_backups=options.max_backups, + ) + except (OSError, ValueError) as e: + console.print(f"[red]Error updating environment variables: {e}[/red]") + return False + + if not changed: + console.print( + "[blue]Environment variables already up to date in KiCad " + "configuration[/blue]" + ) + elif options.dry_run: + console.print( + "[yellow]Would update environment variables in KiCad configuration[/yellow]" + ) + else: + console.print( + "[green]Updated environment variables in KiCad configuration[/green]" + ) + console.print("[blue]Created backup of kicad_common.json[/blue]") + console.print("\n[bold cyan]Environment variables set in KiCad:[/bold cyan]") + for name, value in env_vars.items(): + console.print(f" [cyan]{name}[/cyan] = [blue]{value}[/blue]") + return changed + + +def add_libraries(options: ChangeOptions, libraries: SetupLibraries) -> bool: + """Add the libraries to KiCad's library tables and report the outcome. + + The tables are backed up first, and only when they will change. + + Returns: + Whether the tables changed (would change, in a dry run). + """ + console = get_console() + # Plan first: the backup must hold the tables from before the change. + added, changed = _add_libraries(options, libraries, dry_run=True) + if changed and not options.dry_run: + for table, name in ( + (SYMBOL_TABLE_FILE, "symbol"), + (FOOTPRINT_TABLE_FILE, "footprint"), + ): + table_path = options.kicad_config / table + if table_path.exists(): + create_backup(table_path, options.max_backups) + console.print(f"[blue]Created backup of {name} library table[/blue]") + added, changed = _add_libraries(options, libraries, dry_run=False) + + if not added: + console.print("[blue]No new libraries to add[/blue]") + elif options.dry_run: + console.print( + f"[yellow]Would add {len(added)} libraries to KiCad configuration[/yellow]" + ) + else: + console.print( + f"[green]Added {len(added)} libraries to KiCad configuration[/green]" + ) + return changed + + +def _add_libraries( + options: ChangeOptions, libraries: SetupLibraries, *, dry_run: bool +) -> tuple[set[str], bool]: + """Add (or plan to add) the libraries; return the names and whether needed.""" + return LibraryService.add_libraries( + libraries.symbol_dir, + options.kicad_config, + kicad_3d_dir=libraries.models_dir, + additional_3d_dirs=libraries.model_dirs(), + dry_run=dry_run, + ) + + +def pin_libraries(options: ChangeOptions, symbol_dir: str) -> bool: + """Pin every library in ``symbol_dir`` in KiCad and report the outcome. + + Returns: + Whether the pins changed (would change, in a dry run). A failure is + reported and counts as no change, so setup continues. + """ + console = get_console() + symbols: list[str] = [] + footprints: list[str] = [] + try: + symbols, footprints = list_libraries(symbol_dir) + except (OSError, ValueError) as e: + console.print(f"[red]Error listing libraries to pin: {e}[/red]") + else: + if options.verbose: + console.print( + f"Found {len(symbols)} symbol libraries and {len(footprints)} " + "footprint libraries to pin" + ) + + try: + changed = update_pinned_libraries( + options.kicad_config, + symbol_libs=symbols, + footprint_libs=footprints, + dry_run=options.dry_run, + max_backups=options.max_backups, + ) + except (OSError, ValueError) as e: + console.print(f"[red]Error pinning libraries: {e}[/red]") + return False + + counts = f"{len(symbols)} symbol and {len(footprints)} footprint libraries" + if not changed: + console.print("[blue]All libraries already pinned in KiCad[/blue]") + elif options.dry_run: + console.print(f"[yellow]Would pin {counts} in KiCad[/yellow]") + else: + console.print(f"[green]Pinned {counts} in KiCad[/green]") + return changed diff --git a/kicad_lib_manager/commands/setup/command.py b/kicad_lib_manager/commands/setup/command.py index 707de09..adbcffd 100644 --- a/kicad_lib_manager/commands/setup/command.py +++ b/kicad_lib_manager/commands/setup/command.py @@ -1,125 +1,98 @@ -""" -Setup command implementation for KiCad Library Manager (Typer version). -""" +"""The ``setup`` command: configure KiCad to use the KiLM libraries.""" -import re +import traceback from pathlib import Path +from typing import Annotated import typer -from rich.console import Console from rich.panel import Panel -from rich.table import Table -from ...services.config_service import Config, LibraryDict -from ...services.library_service import LibraryService -from ...utils.backup import create_backup -from ...utils.env_vars import ( - expand_user_path, - find_environment_variables, - update_kicad_env_vars, - update_pinned_libraries, +from kicad_lib_manager.commands.setup import changes +from kicad_lib_manager.commands.setup.changes import ChangeOptions, SetupLibraries +from kicad_lib_manager.commands.setup.sources import ( + LibrarySelection, + LibrarySources, + add_config_libraries, + command_line_sources, + fall_back_to_discovery, + print_source_summary, + requested_names, ) -from ...utils.file_ops import list_libraries -from ...utils.metadata import read_cloud_metadata, read_github_metadata - -console = Console() - - -def fix_invalid_uris( - kicad_config: Path, - backup_first: bool = True, - max_backups: int = 5, - dry_run: bool = False, -) -> bool: - """ - Fix invalid URIs in KiCad library tables, such as paths incorrectly wrapped in ${} syntax. - - Args: - kicad_config: Path to the KiCad configuration directory - backup_first: Whether to create backups before making changes - max_backups: Maximum number of backups to keep - dry_run: If True, don't make any changes - - Returns: - True if changes were made, False otherwise - """ - from ...utils.backup import create_backup - - # Get the library table paths - sym_table = kicad_config / "sym-lib-table" - fp_table = kicad_config / "fp-lib-table" - - changes_made = False - - for table_path in [sym_table, fp_table]: - if table_path.exists(): - # Ensure UTF-8 encoding when reading - with table_path.open(encoding="utf-8") as f: - content = f.read() - - # Look for URIs with invalid environment variable syntax like ${/path/to/lib} - pattern = r'\(uri "\${(\/[^}]+)}\/(.*?)"\)' - if re.search(pattern, content): - changes_made = True - - if not dry_run: - if backup_first: - create_backup(table_path, max_backups) - - # Replace invalid URIs - fixed_content = re.sub(pattern, r'(uri "\\1/\\2")', content) - - # Ensure UTF-8 encoding when writing - with table_path.open("w", encoding="utf-8") as f: - f.write(fixed_content) - - return changes_made - - -def setup( - kicad_lib_dir: str | None = typer.Option( - None, - "--kicad-lib-dir", - envvar="KICAD_USER_LIB", - help="KiCad library directory (uses KICAD_USER_LIB env var if not specified)", - ), - kicad_3d_dir: str | None = typer.Option( - None, - "--kicad-3d-dir", - envvar="KICAD_3D_LIB", - help="KiCad 3D models directory (uses KICAD_3D_LIB env var if not specified)", - ), - threed_lib_dirs: str | None = typer.Option( - None, - "--threed-lib-dirs", - help="Names of 3D model libraries to use (comma-separated, uses all if not specified)", - ), - symbol_lib_dirs: str | None = typer.Option( - None, - "--symbol-lib-dirs", - help="Names of symbol libraries to use (comma-separated, uses current if not specified)", - ), - all_libraries: bool = typer.Option( - False, - "--all-libraries", - help="Set up all configured libraries (both symbols and 3D models)", - ), - max_backups: int = typer.Option( - 5, "--max-backups", help="Maximum number of backups to keep" - ), - dry_run: bool = typer.Option( - False, "--dry-run", help="Show what would be done without making changes" - ), - pin_libraries: bool = typer.Option( - True, - "--pin-libraries/--no-pin-libraries", - help="Add libraries to KiCad pinned libraries for quick access", - ), - verbose: bool = typer.Option( - False, "--verbose", "-v", help="Show more information for debugging" - ), +from kicad_lib_manager.services.library_service import LibraryService +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import ( + DEFAULT_MAX_BACKUPS, + MODELS_DIR_VAR, + SYMBOL_DIR_VAR, +) +from kicad_lib_manager.utils.env_vars import expand_user_path + + +def setup( # noqa: PLR0913 -- each CLI option is a Typer parameter + *, + kicad_lib_dir: Annotated[ + str | None, + typer.Option( + "--kicad-lib-dir", + envvar=SYMBOL_DIR_VAR, + help="KiCad library directory (uses KICAD_USER_LIB env var if not " + "specified)", + ), + ] = None, + kicad_3d_dir: Annotated[ + str | None, + typer.Option( + "--kicad-3d-dir", + envvar=MODELS_DIR_VAR, + help="KiCad 3D models directory (uses KICAD_3D_LIB env var if not " + "specified)", + ), + ] = None, + threed_lib_dirs: Annotated[ + str | None, + typer.Option( + "--threed-lib-dirs", + help="Names of 3D model libraries to use (comma-separated, uses all " + "if not specified)", + ), + ] = None, + symbol_lib_dirs: Annotated[ + str | None, + typer.Option( + "--symbol-lib-dirs", + help="Names of symbol libraries to use (comma-separated, uses current " + "if not specified)", + ), + ] = None, + all_libraries: Annotated[ + bool, + typer.Option( + "--all-libraries", + help="Set up all configured libraries (both symbols and 3D models)", + ), + ] = False, + max_backups: Annotated[ + int, typer.Option("--max-backups", help="Maximum number of backups to keep") + ] = DEFAULT_MAX_BACKUPS, + dry_run: Annotated[ + bool, + typer.Option( + "--dry-run", help="Show what would be done without making changes" + ), + ] = False, + pin_libraries: Annotated[ + bool, + typer.Option( + "--pin-libraries/--no-pin-libraries", + help="Add libraries to KiCad pinned libraries for quick access", + ), + ] = True, + verbose: Annotated[ + bool, + typer.Option("--verbose", "-v", help="Show more information for debugging"), + ] = False, ) -> None: - """Configure KiCad to use libraries in the specified directory + """Configure KiCad to use libraries in the specified directory. This command sets up KiCad to use your configured libraries. It will: @@ -131,449 +104,136 @@ def setup( Each 3D model library can have its own environment variable, allowing multiple 3D model libraries to be used simultaneously. """ - # Show source of library paths - cmd_line_lib_paths = {} - if kicad_lib_dir: - cmd_line_lib_paths["symbols"] = kicad_lib_dir - if verbose: - console.print( - f"Symbol library specified on command line: [blue]{kicad_lib_dir}[/blue]" - ) - - if kicad_3d_dir: - cmd_line_lib_paths["3d"] = kicad_3d_dir - if verbose: - console.print( - f"3D model library specified on command line: [blue]{kicad_3d_dir}[/blue]" + sources = command_line_sources(kicad_lib_dir, kicad_3d_dir, verbose=verbose) + selection = LibrarySelection( + all_libraries=all_libraries, + model_names=requested_names( + threed_lib_dirs, "3D model libraries", verbose=verbose + ), + symbol_names=requested_names( + symbol_lib_dirs, "symbol libraries", verbose=verbose + ), + ) + _read_config(sources, selection, verbose=verbose) + symbol_dir = fall_back_to_discovery(sources) + if verbose: + print_source_summary(sources) + + libraries = SetupLibraries( + symbol_dir=expand_user_path(symbol_dir), + models_dir=None + if sources.models_dir is None + else expand_user_path(sources.models_dir), + library_vars=sources.from_config, + ) + _print_configuration(libraries) + options = ChangeOptions( + kicad_config=_find_kicad_config(dry_run=dry_run, max_backups=max_backups), + dry_run=dry_run, + max_backups=max_backups, + verbose=verbose, + ) + if _apply_changes(options, libraries, pin=pin_libraries) and not dry_run: + get_console().print() + get_console().print( + Panel( + "[bold green]Setup complete! Restart KiCad for changes to take " + "effect.[/bold green]", + title="[bold green]Success[/bold green]", + border_style="green", ) + ) - # Split library names if provided - threed_lib_names = None - if threed_lib_dirs: - threed_lib_names = [name.strip() for name in threed_lib_dirs.split(",")] - if verbose: - console.print(f"Requested 3D model libraries: {threed_lib_names}") - - symbol_lib_names = None - if symbol_lib_dirs: - symbol_lib_names = [name.strip() for name in symbol_lib_dirs.split(",")] - if verbose: - console.print(f"Requested symbol libraries: {symbol_lib_names}") - - # Check Config file for library paths - config_lib_paths: dict[str, str] = {} - config_3d_libs: list[LibraryDict] = [] - config_symbol_libs: list[LibraryDict] = [] - config_obj = None +def _read_config( + sources: LibrarySources, selection: LibrarySelection, *, verbose: bool +) -> None: + """Add the configured libraries, carrying on without them on failure.""" try: - config_obj = Config() - - # Display configuration file location if verbose - if verbose: - config_file = config_obj._get_config_file() - console.print(f"Looking for configuration in: {config_file}") - if config_file.exists(): - console.print("Configuration file exists") - else: - console.print("Configuration file does not exist") - - # Get all configured libraries - all_symbol_libs = config_obj.get_libraries("github") - all_3d_libs = config_obj.get_libraries("cloud") - - if verbose: - console.print( - f"Found {len(all_symbol_libs)} symbol libraries and {len(all_3d_libs)} 3D model libraries in config" - ) - - # Get library paths based on selection criteria - if all_libraries: - # Use all libraries - config_symbol_libs = all_symbol_libs - config_3d_libs = all_3d_libs - else: - # Get libraries by name if specified - if symbol_lib_names: - for name in symbol_lib_names: - for lib in all_symbol_libs: - if lib.get("name") == name: - config_symbol_libs.append(lib) - break - else: - # Get GitHub library path (current library) - github_lib = config_obj.get_symbol_library_path() - if github_lib and not kicad_lib_dir: - for lib in all_symbol_libs: - if lib.get("path") == github_lib: - config_symbol_libs.append(lib) - break - - # Get 3D model libraries by name if specified - if threed_lib_names: - for name in threed_lib_names: - for lib in all_3d_libs: - if lib.get("name") == name: - config_3d_libs.append(lib) - break - else: - # If --all-libraries is not specified and no 3D libraries are specified, - # we'll only set up the current 3D library (if any) - cloud_lib = config_obj.get_3d_library_path() - if cloud_lib and not kicad_3d_dir: - for lib in all_3d_libs: - if lib.get("path") == cloud_lib: - config_3d_libs.append(lib) - break - - # Print what we're setting up - if config_symbol_libs: - console.print() - table = Table( - title="[bold cyan]Setting up symbol libraries[/bold cyan]", - show_header=True, - header_style="bold magenta", - ) - table.add_column("Library", style="cyan", no_wrap=True) - table.add_column("Path", style="blue") - table.add_column("Environment Variable", style="green") - - for lib in config_symbol_libs: - lib_name = lib.get("name", "unnamed") - lib_path = lib.get("path", "unknown") - env_var_display = "None" - - # Read metadata to get environment variable name - try: - metadata = read_github_metadata(Path(lib_path)) - if metadata and "env_var" in metadata: - env_var = metadata["env_var"] - if env_var and isinstance(env_var, str): - # Store all GitHub libraries with their env vars - config_lib_paths[env_var] = lib_path - env_var_display = f"[green]{env_var}[/green]" - else: - env_var_display = "[yellow]Not configured[/yellow]" - else: - env_var_display = "[yellow]No metadata[/yellow]" - except Exception as e: - env_var_display = ( - f"[red]Error: {e}[/red]" if verbose else "[red]Error[/red]" - ) - - # If we're using the first symbol library as the main library - if not kicad_lib_dir and lib == config_symbol_libs[0]: - kicad_lib_dir = lib_path - # For backward compatibility, also use KICAD_USER_LIB as fallback - if "KICAD_USER_LIB" not in config_lib_paths: - config_lib_paths["KICAD_USER_LIB"] = lib_path - - table.add_row(f"[bold]{lib_name}[/bold]", lib_path, env_var_display) - - console.print(table) - - if config_3d_libs: - console.print() - table = Table( - title="[bold cyan]Setting up 3D model libraries[/bold cyan]", - show_header=True, - header_style="bold magenta", - ) - table.add_column("Library", style="cyan", no_wrap=True) - table.add_column("Path", style="blue") - table.add_column("Environment Variable", style="green") - - for lib in config_3d_libs: - lib_name = lib.get("name", "unnamed") - lib_path = lib.get("path", "unknown") - env_var_display = "None" - - # Read metadata to get environment variable name - try: - metadata = read_cloud_metadata(Path(lib_path)) - if metadata and "env_var" in metadata: - env_var = metadata["env_var"] - if env_var and isinstance(env_var, str): - # Store all 3D libraries with their env vars - config_lib_paths[env_var] = lib_path - env_var_display = f"[green]{env_var}[/green]" - else: - env_var_display = "[yellow]Not configured[/yellow]" - else: - env_var_display = "[yellow]No metadata[/yellow]" - except Exception as e: - env_var_display = ( - f"[red]Error: {e}[/red]" if verbose else "[red]Error[/red]" - ) - - # Use the first 3D library as the default if not specified - if not kicad_3d_dir and lib == config_3d_libs[0]: - kicad_3d_dir = lib_path - - table.add_row(f"[bold]{lib_name}[/bold]", lib_path, env_var_display) - - console.print(table) - - except Exception as e: - # If there's any issue with config, continue with environment variables + add_config_libraries(sources, selection, verbose=verbose) + except (OSError, ValueError) as e: + # Environment variables can still provide the libraries. if verbose: + console = get_console() console.print(f"Error reading from config: {e}") - import traceback - console.print(traceback.format_exc()) - # Fall back to environment variables if still not found - env_lib_paths = {} - if not kicad_lib_dir: - env_var = find_environment_variables("KICAD_USER_LIB") - if env_var: - kicad_lib_dir = env_var - env_lib_paths["KICAD_USER_LIB"] = env_var - console.print( - f"[green]Using KiCad library from environment variable:[/green] [blue]{kicad_lib_dir}[/blue]" - ) - else: - console.print("[red]Error: KICAD_USER_LIB not set and not provided[/red]") - console.print( - "[yellow]Consider initializing a library with 'kilm init' first.[/yellow]" - ) - raise typer.Exit(1) - - if not kicad_3d_dir: - env_var = find_environment_variables("KICAD_3D_LIB") - if env_var: - kicad_3d_dir = env_var - env_lib_paths["KICAD_3D_LIB"] = env_var - console.print( - f"[green]Using 3D model library from environment variable:[/green] [blue]{kicad_3d_dir}[/blue]" - ) - else: - console.print( - "[yellow]Warning: KICAD_3D_LIB not set, 3D models might not work correctly[/yellow]" - ) - console.print( - "[yellow]Consider adding a 3D model directory with 'kilm add-3d'[/yellow]" - ) - - # Show summary of where libraries are coming from - if verbose: - console.print("\nSummary of library sources:") - if cmd_line_lib_paths: - console.print(" From command line:") - for lib_type, path in cmd_line_lib_paths.items(): - console.print(f" - {lib_type}: {path}") - - if config_lib_paths: - console.print(" From config file:") - for lib_type, path in config_lib_paths.items(): - console.print(f" - {lib_type}: {path}") - - if env_lib_paths: - console.print(" From environment variables:") - for lib_type, path in env_lib_paths.items(): - console.print(f" - {lib_type}: {path}") - # Expand user home directory if needed - kicad_lib_dir = expand_user_path(kicad_lib_dir) - if kicad_3d_dir: - kicad_3d_dir = expand_user_path(kicad_3d_dir) - - # Create configuration summary panel - config_content = ( - f"[green]Symbol library directory:[/green] [blue]{kicad_lib_dir}[/blue]\n" +def _print_configuration(libraries: SetupLibraries) -> None: + """Print the main library directories setup configures.""" + content = ( + "[green]Symbol library directory:[/green] " + f"[blue]{libraries.symbol_dir}[/blue]\n" ) - if kicad_3d_dir: - config_content += ( - f"[green]3D models directory:[/green]\n[blue]{kicad_3d_dir}[/blue]" - ) + if libraries.models_dir is None: + content += "[yellow]3D models directory: Not configured[/yellow]" else: - config_content += "[yellow]3D models directory: Not configured[/yellow]" - + content += ( + f"[green]3D models directory:[/green]\n[blue]{libraries.models_dir}[/blue]" + ) + console = get_console() console.print() console.print( Panel( - config_content, + content, title="[bold cyan]Configuration Summary[/bold cyan]", border_style="cyan", ) ) - # Find KiCad configuration + +def _find_kicad_config(*, dry_run: bool, max_backups: int) -> Path: + """Find KiCad's configuration and unwrap invalid library paths in it. + + Raises: + typer.Exit: KiCad's configuration is missing or cannot be fixed. + """ + console = get_console() try: kicad_config = LibraryService.find_kicad_config() console.print( f"[green]Found KiCad configuration at:[/green] [blue]{kicad_config}[/blue]" ) - - # Fix any invalid URIs in existing library entries - uri_changes = fix_invalid_uris(kicad_config, True, max_backups, dry_run) - if uri_changes: - if dry_run: - console.print( - "[yellow]Would fix invalid library URIs in KiCad configuration[/yellow]" - ) - else: - console.print( - "[green]Fixed invalid library URIs in KiCad configuration[/green]" - ) + fixed = changes.fix_invalid_uris( + kicad_config, max_backups=max_backups, dry_run=dry_run + ) except Exception as e: console.print(f"[red]Error finding KiCad configuration: {e}[/red]") raise typer.Exit(1) from e - # Prepare environment variables dictionary - env_vars = {} - - # Always include KICAD_USER_LIB for backward compatibility - if kicad_lib_dir: - env_vars["KICAD_USER_LIB"] = kicad_lib_dir - - # Add main 3D library if specified - if kicad_3d_dir: - env_vars["KICAD_3D_LIB"] = kicad_3d_dir - - # Add all custom environment variables from both GitHub and cloud libraries - for var_name, path in config_lib_paths.items(): - env_vars[var_name] = path - - # Initialize variables - env_changes_needed = False - - # Update environment variables in KiCad configuration - try: - env_changes_needed = update_kicad_env_vars( - kicad_config, env_vars, dry_run, max_backups + if fixed and dry_run: + console.print( + "[yellow]Would fix invalid library URIs in KiCad configuration[/yellow]" ) - if env_changes_needed: - if dry_run: - console.print( - "[yellow]Would update environment variables in KiCad configuration[/yellow]" - ) - else: - console.print( - "[green]Updated environment variables in KiCad configuration[/green]" - ) - console.print("[blue]Created backup of kicad_common.json[/blue]") - - # Show all environment variables that were set - console.print( - "\n[bold cyan]Environment variables set in KiCad:[/bold cyan]" - ) - for var_name, value in env_vars.items(): - console.print(f" [cyan]{var_name}[/cyan] = [blue]{value}[/blue]") - else: - console.print( - "[blue]Environment variables already up to date in KiCad configuration[/blue]" - ) - except Exception as e: - console.print(f"[red]Error updating environment variables: {e}[/red]") - # Continue with the rest of the setup, but don't set env_changes_needed to True - - # Add libraries - try: - # Prepare all 3D library paths - three_d_dirs = {} - for var_name, path in config_lib_paths.items(): - if var_name.startswith("KICAD_3D_"): - three_d_dirs[var_name] = path - - # Add the main 3D library if it's not already in the list - if kicad_3d_dir and "KICAD_3D_LIB" not in three_d_dirs: - three_d_dirs["KICAD_3D_LIB"] = kicad_3d_dir - - # Call add_libraries with the main library and all 3D libraries - added_libraries, changes_needed = LibraryService.add_libraries( - kicad_lib_dir, - kicad_config, - kicad_3d_dir=kicad_3d_dir, - additional_3d_dirs=three_d_dirs, - dry_run=dry_run, + elif fixed: + console.print( + "[green]Fixed invalid library URIs in KiCad configuration[/green]" ) + return kicad_config - # Create backups only if changes are needed - if changes_needed and not dry_run: - sym_table = kicad_config / "sym-lib-table" - fp_table = kicad_config / "fp-lib-table" - - if sym_table.exists(): - create_backup(sym_table, max_backups) - console.print("[blue]Created backup of symbol library table[/blue]") - - if fp_table.exists(): - create_backup(fp_table, max_backups) - console.print("[blue]Created backup of footprint library table[/blue]") - - if added_libraries: - if dry_run: - console.print( - f"[yellow]Would add {len(added_libraries)} libraries to KiCad configuration[/yellow]" - ) - else: - console.print( - f"[green]Added {len(added_libraries)} libraries to KiCad configuration[/green]" - ) - else: - console.print("[blue]No new libraries to add[/blue]") - - # Pin libraries if requested - pinned_changes_needed = False - if pin_libraries: - # Extract library names from added_libraries - symbol_libs = [] - footprint_libs = [] - # 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 - - if verbose: - console.print( - f"Found {len(symbol_libs)} symbol libraries and {len(footprint_libs)} footprint libraries to pin" - ) - except Exception as e: - console.print(f"[red]Error listing libraries to pin: {e}[/red]") - - try: - pinned_changes_needed = update_pinned_libraries( - kicad_config, - symbol_libs=symbol_libs, - footprint_libs=footprint_libs, - dry_run=dry_run, - ) - - if pinned_changes_needed: - if dry_run: - console.print( - f"[yellow]Would pin {len(symbol_libs)} symbol and {len(footprint_libs)} footprint libraries in KiCad[/yellow]" - ) - else: - console.print( - f"[green]Pinned {len(symbol_libs)} symbol and {len(footprint_libs)} footprint libraries in KiCad[/green]" - ) - else: - console.print("[blue]All libraries already pinned in KiCad[/blue]") - except Exception as e: - console.print(f"[red]Error pinning libraries: {e}[/red]") +def _apply_changes( + options: ChangeOptions, libraries: SetupLibraries, *, pin: bool +) -> bool: + """Make setup's changes in KiCad and return whether anything changed. - if not changes_needed and not env_changes_needed and not pinned_changes_needed: - console.print("[blue]No changes needed, configuration is up to date[/blue]") - elif dry_run: - console.print("[yellow]Dry run: No changes were made[/yellow]") + Raises: + typer.Exit: The libraries cannot be added. + """ + console = get_console() + env_changed = changes.update_environment(options, libraries.env_vars()) + try: + tables_changed = changes.add_libraries(options, libraries) + pins_changed = pin and changes.pin_libraries(options, libraries.symbol_dir) except Exception as e: console.print(f"[red]Error adding libraries: {e}[/red]") - if verbose: - import traceback - + if options.verbose: console.print(traceback.format_exc()) - raise typer.Exit(1) from None + raise typer.Exit(1) from e - if not dry_run and (changes_needed or env_changes_needed or pinned_changes_needed): - console.print() - console.print( - Panel( - "[bold green]Setup complete! Restart KiCad for changes to take effect.[/bold green]", - title="[bold green]✅ Success[/bold green]", - border_style="green", - ) - ) + changed = env_changed or tables_changed or pins_changed + if not changed: + console.print("[blue]No changes needed, configuration is up to date[/blue]") + elif options.dry_run: + console.print("[yellow]Dry run: No changes were made[/yellow]") + return changed diff --git a/kicad_lib_manager/commands/setup/docs.mdx b/kicad_lib_manager/commands/setup/docs.mdx index 2ebfc48..ab305d7 100644 --- a/kicad_lib_manager/commands/setup/docs.mdx +++ b/kicad_lib_manager/commands/setup/docs.mdx @@ -71,7 +71,7 @@ kilm setup [OPTIONS] ## Behavior Details - **Library Selection:** By default (without `--all-libraries`), `setup` configures only the _current_ symbol/footprint library and the _current_ 3D model library registered in KiLM. Use `--all-libraries` to configure all registered libraries, or `--symbol-lib-dirs` / `--threed-lib-dirs` to configure specific named libraries. -- **Environment Variables:** KiLM attempts to read metadata (e.g., `kilm.yaml` or `.kilm_cloud_metadata`) to find the correct environment variable name (like `KICAD_COMPANY_LIB`) associated with each library path. If specific `--kicad-lib-dir` or `--kicad-3d-dir` options are given, they might influence default variables like `KICAD_USER_LIB` or `KICAD_3D_LIB`. +- **Environment Variables:** KiLM attempts to read metadata (e.g., `kilm.yaml` or `.kilm_metadata`) to find the correct environment variable name (like `KICAD_COMPANY_LIB`) associated with each library path. If specific `--kicad-lib-dir` or `--kicad-3d-dir` options are given, they might influence default variables like `KICAD_USER_LIB` or `KICAD_3D_LIB`. - **Backups:** Backups are crucial for recovery if KiCad's configuration gets corrupted. They are stored in the same directory as the KiCad configuration files and are named with a timestamp. ## Examples diff --git a/kicad_lib_manager/commands/setup/sources.py b/kicad_lib_manager/commands/setup/sources.py new file mode 100644 index 0000000..a0e51b7 --- /dev/null +++ b/kicad_lib_manager/commands/setup/sources.py @@ -0,0 +1,298 @@ +"""Where ``kilm setup`` finds the libraries it configures. + +The command line comes first, then the selected configured libraries, then +``find_library_variable`` discovery for a main directory still missing. +""" + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +import typer +from rich.table import Table + +from kicad_lib_manager.commands.shared import given +from kicad_lib_manager.services.config_service import Config, LibraryDict +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import MODELS_DIR_VAR, SYMBOL_DIR_VAR +from kicad_lib_manager.utils.env_vars import find_library_variable +from kicad_lib_manager.utils.metadata import read_cloud_metadata, read_github_metadata + + +@dataclass +class LibrarySources: + """The main library directories setup configures and where each came from. + + Attributes: + symbol_dir: The main symbol library directory, once known. + models_dir: The main 3D model directory, once known. + from_command_line: Directories given as options, by library type. + from_config: Each selected configured library's environment variable + and path; setup writes these variables to KiCad too. + discovered: Main directories found by ``find_library_variable``, + by variable name. + """ + + symbol_dir: str | None + models_dir: str | None + from_command_line: dict[str, str] = field(default_factory=dict) + from_config: dict[str, str] = field(default_factory=dict) + discovered: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LibrarySelection: + """Which configured libraries setup uses. + + Attributes: + all_libraries: Use every configured library. + symbol_names: Symbol libraries requested by name, or None for the + current one. + model_names: 3D model libraries requested by name, or None for the + current one. + """ + + all_libraries: bool + symbol_names: list[str] | None + model_names: list[str] | None + + +@dataclass(frozen=True) +class _Kind: + """How setup shows one type of configured library.""" + + title: str + read_metadata: Callable[[Path], Mapping[str, object] | None] + + +_SYMBOLS = _Kind("Setting up symbol libraries", read_github_metadata) +_MODELS = _Kind("Setting up 3D model libraries", read_cloud_metadata) + + +def command_line_sources( + symbol_dir: str | None, models_dir: str | None, *, verbose: bool +) -> LibrarySources: + """Start the sources from the directories given on the command line.""" + console = get_console() + sources = LibrarySources(given(symbol_dir), given(models_dir)) + if sources.symbol_dir is not None: + sources.from_command_line["symbols"] = sources.symbol_dir + if verbose: + console.print( + "Symbol library specified on command line: " + f"[blue]{sources.symbol_dir}[/blue]" + ) + if sources.models_dir is not None: + sources.from_command_line["3d"] = sources.models_dir + if verbose: + console.print( + "3D model library specified on command line: " + f"[blue]{sources.models_dir}[/blue]" + ) + return sources + + +def requested_names( + option: str | None, kind: str, *, verbose: bool +) -> list[str] | None: + """Split a comma-separated library name option; None when not given.""" + value = given(option) + if value is None: + return None + names = [name.strip() for name in value.split(",")] + if verbose: + get_console().print(f"Requested {kind}: {names}") + return names + + +def add_config_libraries( + sources: LibrarySources, selection: LibrarySelection, *, verbose: bool +) -> None: + """Add the selected configured libraries to ``sources`` and show them. + + The first selected symbol and 3D model library become the main + directories unless the command line already set them. + + Raises: + OSError: The configuration directory cannot be created or read. + """ + console = get_console() + config = Config() + if verbose: + config_file = config.config_file + console.print(f"Looking for configuration in: {config_file}") + exists = "exists" if config_file.exists() else "does not exist" + console.print(f"Configuration file {exists}") + + symbol_libraries = config.get_libraries("github") + model_libraries = config.get_libraries("cloud") + if verbose: + console.print( + f"Found {len(symbol_libraries)} symbol libraries and " + f"{len(model_libraries)} 3D model libraries in config" + ) + + if selection.all_libraries: + symbols, models = symbol_libraries, model_libraries + else: + current_symbols = config.get_symbol_library_path() + current_models = config.get_3d_library_path() + symbols = _select( + symbol_libraries, + selection.symbol_names, + None if sources.symbol_dir is not None else current_symbols, + ) + models = _select( + model_libraries, + selection.model_names, + None if sources.models_dir is not None else current_models, + ) + + if symbols: + main_var = SYMBOL_DIR_VAR if sources.symbol_dir is None else None + _show_libraries(_SYMBOLS, symbols, sources, main_var=main_var, verbose=verbose) + if sources.symbol_dir is None: + sources.symbol_dir = given(symbols[0].get("path", "unknown")) + if models: + _show_libraries(_MODELS, models, sources, main_var=None, verbose=verbose) + if sources.models_dir is None: + sources.models_dir = given(models[0].get("path", "unknown")) + + +def _select( + libraries: list[LibraryDict], names: list[str] | None, current_path: str | None +) -> list[LibraryDict]: + """Return the libraries named in ``names``, else the current library.""" + if names is not None: + found = (_find(libraries, "name", name) for name in names) + return [library for library in found if library is not None] + current = given(current_path) + if current is None: + return [] + library = _find(libraries, "path", current) + return [] if library is None else [library] + + +def _find( + libraries: list[LibraryDict], key: Literal["name", "path"], value: str +) -> LibraryDict | None: + """Return the first library whose ``key`` is ``value``.""" + return next((library for library in libraries if library.get(key) == value), None) + + +def _show_libraries( + kind: _Kind, + libraries: list[LibraryDict], + sources: LibrarySources, + *, + main_var: str | None, + verbose: bool, +) -> None: + """Show ``libraries`` and record their environment variables. + + When ``main_var`` is set, the first library also provides it unless a + library's own variable already did. + """ + table = Table( + title=f"[bold cyan]{kind.title}[/bold cyan]", + show_header=True, + header_style="bold magenta", + ) + table.add_column("Library", style="cyan", no_wrap=True) + table.add_column("Path", style="blue") + table.add_column("Environment Variable", style="green") + for index, library in enumerate(libraries): + path = library.get("path", "unknown") + env_var, shown = _environment_variable(kind, path, verbose=verbose) + if env_var is not None: + sources.from_config[env_var] = path + if index == 0 and main_var is not None: + sources.from_config.setdefault(main_var, path) + table.add_row(f"[bold]{library.get('name', 'unnamed')}[/bold]", path, shown) + console = get_console() + console.print() + console.print(table) + + +def _environment_variable( + kind: _Kind, path: str, *, verbose: bool +) -> tuple[str | None, str]: + """Return a library's environment variable, if any, and how to show it.""" + try: + metadata = kind.read_metadata(Path(path)) + except OSError as e: + return None, (f"[red]Error: {e}[/red]" if verbose else "[red]Error[/red]") + if metadata is None or "env_var" not in metadata: + return None, "[yellow]No metadata[/yellow]" + env_var = metadata["env_var"] + if isinstance(env_var, str) and env_var: + return env_var, f"[green]{env_var}[/green]" + return None, "[yellow]Not configured[/yellow]" + + +def fall_back_to_discovery(sources: LibrarySources) -> str: + """Fill missing main directories through ``find_library_variable``. + + It searches the KiLM configuration, the current directory, the + environment, and shell startup files. The printed labels say + "environment variable" because that is how users set these paths. + + Returns: + The main symbol library directory. + + Raises: + typer.Exit: No symbol library directory is known. + """ + console = get_console() + symbol_dir = sources.symbol_dir + if symbol_dir is None: + symbol_dir = given(find_library_variable(SYMBOL_DIR_VAR)) + if symbol_dir is None: + console.print("[red]Error: KICAD_USER_LIB not set and not provided[/red]") + console.print( + "[yellow]Consider initializing a library with 'kilm init' " + "first.[/yellow]" + ) + raise typer.Exit(1) + sources.symbol_dir = symbol_dir + sources.discovered[SYMBOL_DIR_VAR] = symbol_dir + console.print( + "[green]Using KiCad library from environment variable:[/green] " + f"[blue]{symbol_dir}[/blue]" + ) + + if sources.models_dir is None: + models_dir = given(find_library_variable(MODELS_DIR_VAR)) + if models_dir is None: + console.print( + "[yellow]Warning: KICAD_3D_LIB not set, 3D models might not work " + "correctly[/yellow]" + ) + console.print( + "[yellow]Consider adding a 3D model directory with " + "'kilm add-3d'[/yellow]" + ) + else: + sources.models_dir = models_dir + sources.discovered[MODELS_DIR_VAR] = models_dir + console.print( + "[green]Using 3D model library from environment variable:[/green] " + f"[blue]{models_dir}[/blue]" + ) + return symbol_dir + + +def print_source_summary(sources: LibrarySources) -> None: + """Print where each library directory came from.""" + console = get_console() + console.print("\nSummary of library sources:") + for heading, paths in ( + ("From command line", sources.from_command_line), + ("From config file", sources.from_config), + ("From environment variables", sources.discovered), + ): + if paths: + console.print(f" {heading}:") + for library_type, path in paths.items(): + console.print(f" - {library_type}: {path}") diff --git a/kicad_lib_manager/commands/shared.py b/kicad_lib_manager/commands/shared.py new file mode 100644 index 0000000..76da55e --- /dev/null +++ b/kicad_lib_manager/commands/shared.py @@ -0,0 +1,47 @@ +"""Option handling shared by several commands.""" + +import typer + +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import SYMBOL_DIR_VAR +from kicad_lib_manager.utils.env_vars import ( + expand_user_path, + find_library_variable, +) + + +def given(value: str | None) -> str | None: + """Return ``value``, or None when an option was omitted or set to ``""``. + + Commands treat an empty option or environment variable as not given, so + callers can test for None afterwards. + """ + return None if value == "" else value + + +def parse_assignments(assignments: list[str]) -> dict[str, str]: + """Parse ``name=value`` options into a mapping. + + Splits at the first ``=`` and strips both sides. Entries without ``=`` + are ignored, and a later assignment to a name replaces an earlier one. + """ + parsed: dict[str, str] = {} + for assignment in assignments: + key, separator, value = assignment.partition("=") + if separator: + parsed[key.strip()] = value.strip() + return parsed + + +def resolve_kicad_lib_dir(option: str | None) -> str: + """Return the symbol and footprint library directory with ``~`` expanded. + + Uses ``option`` when given, otherwise ``KICAD_USER_LIB`` from the places + ``find_library_variable`` searches. The value is not checked to be a + directory. Prints an error and exits with status 1 when neither is given. + """ + directory = given(option) or given(find_library_variable(SYMBOL_DIR_VAR)) + if directory is None: + get_console().print("[red]Error: KICAD_USER_LIB not set and not provided[/red]") + raise typer.Exit(1) + return expand_user_path(directory) diff --git a/kicad_lib_manager/commands/status/__init__.py b/kicad_lib_manager/commands/status/__init__.py index 239e435..0ebb23b 100644 --- a/kicad_lib_manager/commands/status/__init__.py +++ b/kicad_lib_manager/commands/status/__init__.py @@ -1,3 +1,5 @@ -from .command import status +"""The ``status`` command: summarize the KiLM and KiCad configuration.""" + +from kicad_lib_manager.commands.status.command import status __all__ = ["status"] diff --git a/kicad_lib_manager/commands/status/command.py b/kicad_lib_manager/commands/status/command.py index 61860d6..3922cc4 100644 --- a/kicad_lib_manager/commands/status/command.py +++ b/kicad_lib_manager/commands/status/command.py @@ -1,26 +1,50 @@ +"""The ``status`` command: summarize the KiLM and KiCad configuration. + +Each section reports its own read errors and the command carries on, so one +unreadable file does not hide the rest of the status. """ -Status command implementation for KiCad Library Manager (Typer version). -""" -import json +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"yaml"} + +from collections.abc import Callable from pathlib import Path from typing import Annotated import typer import yaml -from rich.console import Console from rich.panel import Panel from rich.table import Table from rich.text import Text -from ...services.kicad_service import KiCadService -from ...utils.constants import CONFIG_DIR_NAME, CONFIG_FILE_NAME -from ...utils.metadata import read_cloud_metadata, read_github_metadata - -console = Console() +from kicad_lib_manager.services.library_service import LibraryService +from kicad_lib_manager.services.user_paths import user_config_file +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import KICAD_COMMON_FILE +from kicad_lib_manager.utils.file_ops import ( + LibraryTableEntry, + list_configured_libraries, +) +from kicad_lib_manager.utils.kicad_common import ( + PINNED_FOOTPRINTS_KEY, + PINNED_SYMBOLS_KEY, + JsonObject, + as_json_object, + as_list, + load_json_object, + pinned_names, + session_of, +) +from kicad_lib_manager.utils.metadata import read_cloud_metadata, read_github_metadata + +LEGACY_PINNED_FILE = "pinned" +LEGACY_PINNED_FOOTPRINTS_KEY = "pinned_footprint_libs" + +MetadataReader = Callable[[Path], object | None] def status( + *, verbose: Annotated[ bool, typer.Option( @@ -30,286 +54,230 @@ def status( ), ] = False, ) -> None: - """Show the current KiCad configuration status""" + """Show the current KiCad configuration status.""" + console = get_console() try: - # Initialize services - kicad_service = KiCadService() - - # Show KILM configuration first _show_kilm_configuration() - console.print("\n[bold cyan]KiCad Configuration[/bold cyan]") - - kicad_config = kicad_service.find_kicad_config_dir() + kicad_config = LibraryService.find_kicad_config() console.print(f"KiCad configuration directory: [blue]{kicad_config}[/blue]") - - # Check environment variables in KiCad common _show_kicad_environment_variables(kicad_config) - - # Check pinned libraries - _check_pinned_libraries(kicad_config, kicad_service) - - # Check configured libraries (only in verbose mode) + _show_pinned_libraries(kicad_config) if verbose: - _show_configured_libraries(kicad_config, kicad_service) - + _show_configured_libraries(kicad_config) except Exception as e: console.print(f"[red]Error getting KiCad configuration: {e}[/red]") raise typer.Exit(1) from e def _show_kilm_configuration() -> None: - """Show KILM configuration section""" + """Show the libraries and settings in the KiLM configuration file.""" + console = get_console() try: - config_file = Path.home() / ".config" / CONFIG_DIR_NAME / CONFIG_FILE_NAME - if config_file.exists(): - console.print("[bold cyan]KILM Configuration[/bold cyan]") - try: - with config_file.open() as f: - config_data = yaml.safe_load(f) - - if config_data is not None: - _show_configured_libraries_table(config_data) - _show_kilm_settings(config_data) - else: - console.print( - "[yellow]Configuration file is empty or invalid[/yellow]" - ) - - except Exception as e: - console.print(f"[red]Error reading configuration: {e}[/red]") - else: - console.print( - "[yellow]No KILM configuration file found. Run 'kilm init' to create one.[/yellow]" - ) - except Exception as e: + config_file = user_config_file() + exists = config_file.exists() + except (OSError, RuntimeError) as e: console.print(f"[red]Error reading KILM configuration: {e}[/red]") + return + if not exists: + console.print( + "[yellow]No KILM configuration file found. Run 'kilm init' to create " + "one.[/yellow]" + ) + return - -def _show_configured_libraries_table(config_data: dict) -> None: - """Show configured libraries in a table format""" - if not config_data or not config_data.get("libraries"): - console.print("[yellow] No libraries configured[/yellow]") + console.print("[bold cyan]KILM Configuration[/bold cyan]") + try: + # Parsing the open file keeps its name in YAML error messages. + with config_file.open(encoding="utf-8") as stream: + loaded: object = yaml.safe_load(stream) + except (OSError, ValueError, yaml.YAMLError) as e: + console.print(f"[red]Error reading configuration: {e}[/red]") + return + config_data = as_json_object(loaded) + if config_data is None: + console.print("[yellow]Configuration file is empty or invalid[/yellow]") return + _show_configured_libraries_tables(config_data) + _show_kilm_settings(config_data) - # Create tables for different library types - github_libs = [ - lib for lib in config_data["libraries"] if lib.get("type") == "github" + +def _show_library_table( + title: str, + libraries: list[JsonObject], + current_library: object, + read_metadata: MetadataReader, +) -> None: + """Print one table of KiLM libraries, marking the current one.""" + if not libraries: + return + table = Table(title=title, show_header=True) + table.add_column("Name", style="cyan", no_wrap=True) + table.add_column("Path", style="blue") + table.add_column("Status", justify="center") + table.add_column("Metadata", justify="center") + for library in libraries: + path = str(library.get("path", "unknown")) + metadata = read_metadata(Path(path)) + table.add_row( + str(library.get("name", "unnamed")), + path, + "Current" if current_library == path else "", + "Yes" if metadata is not None and metadata else "Missing", + ) + get_console().print(table) + + +def _show_configured_libraries_tables(config_data: JsonObject) -> None: + """Show the configured GitHub and cloud libraries as tables.""" + libraries = [ + library + for entry in as_list(config_data.get("libraries")) + if (library := as_json_object(entry)) is not None ] - cloud_libs = [lib for lib in config_data["libraries"] if lib.get("type") == "cloud"] + if not libraries: + get_console().print("[yellow] No libraries configured[/yellow]") + return current_library = config_data.get("current_library", "") - - if github_libs: - table = Table(title="GitHub Libraries (symbols/footprints)", show_header=True) - table.add_column("Name", style="cyan", no_wrap=True) - table.add_column("Path", style="blue") - table.add_column("Status", justify="center") - table.add_column("Metadata", justify="center") - - for lib in github_libs: - name = lib.get("name", "unnamed") - path = lib.get("path", "unknown") - is_current = "✓ Current" if current_library == path else "" - - # Check metadata - try: - has_metadata = "✓" if read_github_metadata(Path(path)) else "✗" - except Exception: - has_metadata = "?" - - table.add_row(name, path, is_current, has_metadata) - - console.print(table) - - if cloud_libs: - table = Table(title="Cloud Libraries (3D models)", show_header=True) - table.add_column("Name", style="cyan", no_wrap=True) - table.add_column("Path", style="blue") - table.add_column("Status", justify="center") - table.add_column("Metadata", justify="center") - - for lib in cloud_libs: - name = lib.get("name", "unnamed") - path = lib.get("path", "unknown") - is_current = "✓ Current" if current_library == path else "" - - # Check metadata - try: - has_metadata = "✓" if read_cloud_metadata(Path(path)) else "✗" - except Exception: - has_metadata = "?" - - table.add_row(name, path, is_current, has_metadata) - - console.print(table) - - -def _show_kilm_settings(config_data: dict) -> None: - """Show KILM settings""" + _show_library_table( + "GitHub Libraries (symbols/footprints)", + [library for library in libraries if library.get("type") == "github"], + current_library, + read_github_metadata, + ) + _show_library_table( + "Cloud Libraries (3D models)", + [library for library in libraries if library.get("type") == "cloud"], + current_library, + read_cloud_metadata, + ) + + +def _show_kilm_settings(config_data: JsonObject) -> None: + """Show the current library and backup settings.""" if not config_data: return - settings_text = Text() - - # Current library - current_lib = config_data.get("current_library") - if current_lib: - settings_text.append(f"Current Library: {current_lib}\n", style="green") + current_library = config_data.get("current_library") + if current_library: + settings_text.append(f"Current Library: {current_library}\n", style="green") else: settings_text.append("No current library set\n", style="yellow") - - # Max backups max_backups = config_data.get("max_backups") if max_backups is not None: settings_text.append(f"Max Backups: {max_backups}", style="blue") - - if settings_text.plain: - console.print(Panel(settings_text, title="Settings", border_style="blue")) + get_console().print(Panel(settings_text, title="Settings", border_style="blue")) def _show_kicad_environment_variables(kicad_config: Path) -> None: - """Show KiCad environment variables""" - kicad_common = kicad_config / "kicad_common.json" + """Show the path variables set in kicad_common.json.""" + console = get_console() + kicad_common = kicad_config / KICAD_COMMON_FILE if not kicad_common.exists(): console.print("[yellow]No kicad_common.json found[/yellow]") return - try: - with kicad_common.open() as f: - common_config = json.load(f) - - console.print("\n[bold]Environment Variables in KiCad:[/bold]") - - if "environment" in common_config and "vars" in common_config["environment"]: - env_vars = common_config["environment"]["vars"] - if env_vars: - table = Table(show_header=True) - table.add_column("Variable", style="cyan", no_wrap=True) - table.add_column("Value", style="blue") - - for key, value in env_vars.items(): - table.add_row(key, str(value)) - - console.print(table) - else: - console.print("[yellow] No environment variables set[/yellow]") - else: - console.print("[yellow] No environment variables found[/yellow]") - except Exception as e: + common_config = load_json_object(kicad_common) + except (OSError, ValueError) as e: console.print(f"[red]Error reading KiCad common configuration: {e}[/red]") + return - -def _check_pinned_libraries(kicad_config: Path, kicad_service: KiCadService) -> None: - """Check and display pinned libraries""" - _ = kicad_service # Suppress unused warning, TODO: implement functionality - kicad_common = kicad_config / "kicad_common.json" + console.print("\n[bold]Environment Variables in KiCad:[/bold]") + environment = as_json_object(common_config.get("environment")) + if environment is None or "vars" not in environment: + console.print("[yellow] No environment variables found[/yellow]") + return + variables = as_json_object(environment["vars"]) + if variables is None or not variables: + console.print("[yellow] No environment variables set[/yellow]") + return + table = Table(show_header=True) + table.add_column("Variable", style="cyan", no_wrap=True) + table.add_column("Value", style="blue") + for key, value in variables.items(): + table.add_row(key, str(value)) + console.print(table) + + +def _print_pinned(symbols: list[str], footprints: list[str], none_found: str) -> None: + """List pinned libraries, or print ``none_found`` when there are none.""" + console = get_console() + for heading, names in (("Symbol", symbols), ("Footprint", footprints)): + if names: + console.print(f"[cyan]{heading} Libraries:[/cyan]") + for name in names: + console.print(f" • {name}") + if not symbols and not footprints: + console.print(f"[yellow] {none_found}[/yellow]") + + +def _show_pinned_libraries(kicad_config: Path) -> None: + """Show the pinned libraries from kicad_common.json. + + Falls back to the separate ``pinned`` file of older KiCad versions when + kicad_common.json is missing or unreadable. + """ + console = get_console() + kicad_common = kicad_config / KICAD_COMMON_FILE if kicad_common.exists(): try: - with kicad_common.open() as f: - common_config = json.load(f) - - found_pinned = False - console.print("\n[bold]Pinned Libraries in KiCad:[/bold]") - - # Check for pinned symbol libraries - if ( - "session" in common_config - and "pinned_symbol_libs" in common_config["session"] - ): - sym_libs = common_config["session"]["pinned_symbol_libs"] - if sym_libs: - found_pinned = True - console.print("[cyan]Symbol Libraries:[/cyan]") - for lib in sym_libs: - console.print(f" • {lib}") - - # Check for pinned footprint libraries - if ( - "session" in common_config - and "pinned_fp_libs" in common_config["session"] - ): - fp_libs = common_config["session"]["pinned_fp_libs"] - if fp_libs: - found_pinned = True - console.print("[cyan]Footprint Libraries:[/cyan]") - for lib in fp_libs: - console.print(f" • {lib}") - - if not found_pinned: - console.print( - "[yellow] No pinned libraries found in kicad_common.json[/yellow]" - ) - - return - except Exception as e: + session = session_of(load_json_object(kicad_common)) or {} + except (OSError, ValueError) as e: console.print( f"[red]Error reading pinned libraries from kicad_common.json: {e}[/red]" ) - - # Fall back to the old method of looking for a separate pinned file - pinned_libs = kicad_config / "pinned" - if pinned_libs.exists(): - try: - with pinned_libs.open() as f: - pinned_config = json.load(f) - - console.print("\n[bold]Pinned Libraries in KiCad (legacy format):[/bold]") - found_pinned = False - - if "pinned_symbol_libs" in pinned_config: - sym_libs = pinned_config["pinned_symbol_libs"] - if sym_libs: - found_pinned = True - console.print("[cyan]Symbol Libraries:[/cyan]") - for lib in sym_libs: - console.print(f" • {lib}") - - if "pinned_footprint_libs" in pinned_config: - fp_libs = pinned_config["pinned_footprint_libs"] - if fp_libs: - found_pinned = True - console.print("[cyan]Footprint Libraries:[/cyan]") - for lib in fp_libs: - console.print(f" • {lib}") - - if not found_pinned: - console.print("[yellow] No pinned libraries found[/yellow]") - except Exception as e: - console.print(f"[red]Error reading pinned libraries: {e}[/red]") - else: - console.print("\n[yellow]No pinned libraries file found[/yellow]") + else: + console.print("\n[bold]Pinned Libraries in KiCad:[/bold]") + _print_pinned( + pinned_names(session, PINNED_SYMBOLS_KEY), + pinned_names(session, PINNED_FOOTPRINTS_KEY), + "No pinned libraries found in kicad_common.json", + ) + return + _show_legacy_pinned_libraries(kicad_config / LEGACY_PINNED_FILE) -def _show_configured_libraries(kicad_config: Path, kicad_service: KiCadService) -> None: - """Show configured libraries in KiCad""" +def _show_legacy_pinned_libraries(pinned_file: Path) -> None: + """Show the pinned libraries from a pre-KiCad 6 ``pinned`` file.""" + console = get_console() + if not pinned_file.exists(): + console.print("\n[yellow]No pinned libraries file found[/yellow]") + return try: - sym_libs, fp_libs = kicad_service.get_configured_libraries(kicad_config) - - console.print("\n[bold]Configured Symbol Libraries:[/bold]") - if sym_libs: - table = Table(show_header=True) - table.add_column("Library Name", style="cyan", no_wrap=True) - table.add_column("URI", style="blue") - - for lib in sym_libs: - table.add_row(lib.get("name", ""), lib.get("uri", "")) - - console.print(table) - else: - console.print("[yellow] No symbol libraries configured[/yellow]") - - console.print("\n[bold]Configured Footprint Libraries:[/bold]") - if fp_libs: - table = Table(show_header=True) - table.add_column("Library Name", style="cyan", no_wrap=True) - table.add_column("URI", style="blue") + pinned_config = load_json_object(pinned_file) + except (OSError, ValueError) as e: + console.print(f"[red]Error reading pinned libraries: {e}[/red]") + return + console.print("\n[bold]Pinned Libraries in KiCad (legacy format):[/bold]") + _print_pinned( + pinned_names(pinned_config, PINNED_SYMBOLS_KEY), + pinned_names(pinned_config, LEGACY_PINNED_FOOTPRINTS_KEY), + "No pinned libraries found", + ) + + +def _print_configured(kind: str, libraries: list[LibraryTableEntry]) -> None: + """Print the libraries in one KiCad library table.""" + console = get_console() + console.print(f"\n[bold]Configured {kind} Libraries:[/bold]") + if not libraries: + console.print(f"[yellow] No {kind.lower()} libraries configured[/yellow]") + return + table = Table(show_header=True) + table.add_column("Library Name", style="cyan", no_wrap=True) + table.add_column("URI", style="blue") + for library in libraries: + table.add_row(library["name"], library.get("uri", "")) + console.print(table) - for lib in fp_libs: - table.add_row(lib.get("name", ""), lib.get("uri", "")) - console.print(table) - else: - console.print("[yellow] No footprint libraries configured[/yellow]") - except Exception as e: - console.print(f"[red]Error listing configured libraries: {e}[/red]") +def _show_configured_libraries(kicad_config: Path) -> None: + """Show the libraries in KiCad's symbol and footprint library tables.""" + try: + symbols, footprints = list_configured_libraries(kicad_config) + except (OSError, ValueError) as e: + get_console().print(f"[red]Error listing configured libraries: {e}[/red]") + return + _print_configured("Symbol", symbols) + _print_configured("Footprint", footprints) diff --git a/kicad_lib_manager/commands/sync/__init__.py b/kicad_lib_manager/commands/sync/__init__.py index d56ff97..011c27f 100644 --- a/kicad_lib_manager/commands/sync/__init__.py +++ b/kicad_lib_manager/commands/sync/__init__.py @@ -1,3 +1,5 @@ -from .command import sync +"""The ``sync`` command: update git-based libraries.""" + +from kicad_lib_manager.commands.sync.command import sync __all__ = ["sync"] diff --git a/kicad_lib_manager/commands/sync/command.py b/kicad_lib_manager/commands/sync/command.py index 878334a..30351eb 100644 --- a/kicad_lib_manager/commands/sync/command.py +++ b/kicad_lib_manager/commands/sync/command.py @@ -1,21 +1,186 @@ -""" -Sync command implementation for KiCad Library Manager. -Performs 'git pull' on all configured GitHub libraries (symbols/footprints). +"""The ``sync`` command: ``git pull`` every configured GitHub library. + +After pulling, sync reports libraries whose new commits add symbol, +footprint, or template content, since KiCad only sees those after +``kilm setup``. """ +import shutil import subprocess +from collections import Counter +from enum import Enum from pathlib import Path from typing import Annotated import typer -from rich.console import Console -from ...services.config_service import Config +from kicad_lib_manager.commands.setup.command import setup +from kicad_lib_manager.services.config_service import Config, LibraryDict +from kicad_lib_manager.utils.console import get_console + + +class _Outcome(Enum): + """What syncing one library did.""" + + UPDATED = "updated" + UP_TO_DATE = "up to date" + SKIPPED = "skipped" + FAILED = "failed" + + +NewContent = list[tuple[str, list[str]]] + + +def _run_git(repository: Path, *args: str) -> subprocess.CompletedProcess[str]: + """Run git with ``args`` in ``repository`` and capture its text output. + + Raises: + FileNotFoundError: git is not installed. + """ + git = shutil.which("git") + if git is None: + msg = "git executable not found" + raise FileNotFoundError(msg) + return subprocess.run( # noqa: S603 -- fixed git subcommands; the variable parts are revisions + [git, *args], + cwd=repository, + capture_output=True, + text=True, + check=False, + ) + + +def _head_revision(repository: Path) -> str | None: + """Return the commit HEAD points at, or None when git cannot tell.""" + result = _run_git(repository, "rev-parse", "--verify", "--quiet", "HEAD") + revision = result.stdout.strip() + return revision if result.returncode == 0 and revision else None + + +def _skip_reason(path: Path) -> str | None: + """Return why ``path`` cannot be pulled, or None when it is a git clone.""" + if not path.exists(): + return f"Path does not exist: {path}" + if not (path / ".git").is_dir(): + return f"Not a git repository: {path}" + return None + + +def _pull(path: Path, *, verbose: bool) -> tuple[_Outcome, list[str]]: + """Pull one library and return the outcome and any new content kinds.""" + console = get_console() + revision_before_pull = _head_revision(path) + result = _run_git(path, "pull") + if result.returncode != 0: + console.print(f" [red]Failed: {result.stderr.strip()}[/red]") + return _Outcome.FAILED, [] + + # git words its messages in the user's language; HEAD is exact. + updated = _head_revision(path) != revision_before_pull + if verbose: + output = result.stdout.strip() or "Already up to date." + console.print(f" [green]Success: {output}[/green]") + if not updated: + console.print(" [blue]Up to date[/blue]") + return _Outcome.UP_TO_DATE, [] + + console.print(" [green]Updated[/green]") + new_content = check_for_library_changes(path, revision_before_pull or "HEAD~1") + if verbose and new_content: + console.print( + " [yellow]Detected new library content: " + f"{', '.join(new_content)}[/yellow]" + ) + return _Outcome.UPDATED, new_content + + +def _sync_library( + library: LibraryDict, *, dry_run: bool, verbose: bool +) -> tuple[_Outcome, list[str]]: + """Sync one configured library and return the outcome and new content.""" + console = get_console() + name = library.get("name", "unnamed") + configured_path = library.get("path") + if not configured_path: + console.print(f" [yellow]Skipping {name}: No path defined[/yellow]") + return _Outcome.SKIPPED, [] + path = Path(configured_path) + reason = _skip_reason(path) + if reason is not None: + console.print(f" [yellow]Skipping {name}: {reason}[/yellow]") + return _Outcome.SKIPPED, [] + + console.print(f" [cyan]Syncing {name} at {path}...[/cyan]") + if dry_run: + console.print(f" [blue]Dry run: would execute 'git pull' in {path}[/blue]") + return _Outcome.UPDATED, [] + try: + return _pull(path, verbose=verbose) + except (OSError, subprocess.SubprocessError) as e: + console.print(f" [red]Error: {e!s}[/red]") + return _Outcome.FAILED, [] + + +def _sync_all( + libraries: list[LibraryDict], *, dry_run: bool, verbose: bool +) -> tuple[Counter[_Outcome], NewContent]: + """Sync every library; return the outcome counts and the new content.""" + outcomes: Counter[_Outcome] = Counter() + new_content: NewContent = [] + for library in libraries: + outcome, kinds = _sync_library(library, dry_run=dry_run, verbose=verbose) + outcomes[outcome] += 1 + if kinds: + new_content.append((library.get("name", "unnamed"), kinds)) + return outcomes, new_content + + +def _print_summary(outcomes: Counter[_Outcome]) -> None: + """Print how many libraries ended in each outcome.""" + console = get_console() + console.print("\n[bold cyan]Sync Summary:[/bold cyan]") + console.print(f" [green]{outcomes[_Outcome.UPDATED]}[/green] libraries synced") + console.print( + f" [blue]{outcomes[_Outcome.UP_TO_DATE]}[/blue] libraries up to date" + ) + console.print(f" [yellow]{outcomes[_Outcome.SKIPPED]}[/yellow] libraries skipped") + console.print(f" [red]{outcomes[_Outcome.FAILED]}[/red] libraries failed") + + +def _report_new_content(new_content: NewContent, *, auto_setup: bool) -> None: + """Report new library content, running setup when ``auto_setup`` is set.""" + console = get_console() + if not new_content: + console.print( + "\n[blue]No new libraries detected that would require running " + "'kilm setup'.[/blue]" + ) + console.print( + "[blue]Use 'kilm status' to check your current configuration.[/blue]" + ) + return -console = Console() + console.print("\n[yellow]New library content detected in:[/yellow]") + for name, kinds in new_content: + console.print(f" - [cyan]{name}[/cyan]: {', '.join(kinds)}") + if auto_setup: + console.print( + "\n[green]Running 'kilm setup' to configure new libraries...[/green]" + ) + setup() + return + console.print( + "\n[yellow]You should run 'kilm setup' to configure the new libraries " + "in KiCad.[/yellow]" + ) + console.print( + "[blue]Run 'kilm sync --auto-setup' next time to automatically run " + "setup after sync.[/blue]" + ) def sync( + *, dry_run: Annotated[ bool, typer.Option(help="Show what would be synced without making changes") ] = False, @@ -37,292 +202,93 @@ def sync( and recommend running 'kilm setup' if needed. Use --auto-setup to run setup automatically when new libraries are detected. """ + console = get_console() try: - config = Config() - - # Get GitHub libraries from config (symbols/footprints) - libraries = config.get_libraries(library_type="github") - + libraries = Config().get_libraries(library_type="github") if not libraries: console.print( - "[yellow]No GitHub libraries configured. Use 'kilm init' to add a library.[/yellow]" + "[yellow]No GitHub libraries configured. Use 'kilm init' to add a " + "library.[/yellow]" ) return - console.print( f"[cyan]Syncing {len(libraries)} KiCad GitHub libraries...[/cyan]" ) - - updated_count = 0 # Actually changed - up_to_date_count = 0 # Already at latest version - skipped_count = 0 # Could not sync (not git, etc.) - failed_count = 0 # Git pull failed - - # Track libraries that have changes that might require setup - libraries_with_changes: list[tuple[str, Path, list[str]]] = [] - - for lib in libraries: - lib_name = lib.get("name", "unnamed") - lib_path = lib.get("path") - - if not lib_path: - console.print( - f" [yellow]Skipping {lib_name}: No path defined[/yellow]" - ) - skipped_count += 1 - continue - - lib_path = Path(lib_path) - if not lib_path.exists(): - console.print( - f" [yellow]Skipping {lib_name}: Path does not exist: {lib_path}[/yellow]" - ) - skipped_count += 1 - continue - - git_dir = lib_path / ".git" - if not git_dir.exists() or not git_dir.is_dir(): - console.print( - f" [yellow]Skipping {lib_name}: Not a git repository: {lib_path}[/yellow]" - ) - skipped_count += 1 - continue - - # Prepare to run git pull - console.print(f" [cyan]Syncing {lib_name} at {lib_path}...[/cyan]") - - if dry_run: - console.print( - f" [blue]Dry run: would execute 'git pull' in {lib_path}[/blue]" - ) - updated_count += 1 - continue - - try: - # Run git pull - result = subprocess.run( - ["git", "pull"], - cwd=lib_path, - capture_output=True, - text=True, - check=False, - ) - - if result.returncode == 0: - output = result.stdout.strip() or "Already up to date." - is_updated = "Already up to date" not in output - - if verbose: - console.print(f" [green]Success: {output}[/green]") - # Also show the short status for consistency - if is_updated: - console.print(" [green]Updated[/green]") - else: - console.print(" [blue]Up to date[/blue]") - else: - if is_updated: - console.print(" [green]Updated[/green]") - else: - console.print(" [blue]Up to date[/blue]") - - # Update counters regardless of verbose flag - if is_updated: - updated_count += 1 - else: - up_to_date_count += 1 - - # Check if there are new library files that would require setup - if is_updated: - changes_require_setup = check_for_library_changes(lib_path) - if changes_require_setup: - libraries_with_changes.append( - (lib_name, lib_path, changes_require_setup) - ) - if verbose: - console.print( - f" [yellow]Detected new library content: {', '.join(changes_require_setup)}[/yellow]" - ) - else: - console.print(f" [red]Failed: {result.stderr.strip()}[/red]") - failed_count += 1 - - except Exception as e: - console.print(f" [red]Error: {str(e)}[/red]") - failed_count += 1 - - # Summary - console.print("\n[bold cyan]Sync Summary:[/bold cyan]") - console.print(f" [green]{updated_count}[/green] libraries synced") - console.print(f" [blue]{up_to_date_count}[/blue] libraries up to date") - console.print(f" [yellow]{skipped_count}[/yellow] libraries skipped") - console.print(f" [red]{failed_count}[/red] libraries failed") - - # If libraries with changes were detected, suggest running setup - if libraries_with_changes: - console.print("\n[yellow]New library content detected in:[/yellow]") - for lib_name, _lib_path, changes in libraries_with_changes: - console.print(f" - [cyan]{lib_name}[/cyan]: {', '.join(changes)}") - - if auto_setup: - console.print( - "\n[green]Running 'kilm setup' to configure new libraries...[/green]" - ) - # Import at runtime to avoid circular imports - try: - from ...commands.setup.command import setup as setup_cmd - - setup_cmd() - except ImportError: - console.print( - "[red]Error: Could not import setup command. Please run 'kilm setup' manually.[/red]" - ) - else: - console.print( - "\n[yellow]You should run 'kilm setup' to configure the new libraries in KiCad.[/yellow]" - ) - console.print( - "[blue]Run 'kilm sync --auto-setup' next time to automatically run setup after sync.[/blue]" - ) - else: - console.print( - "\n[blue]No new libraries detected that would require running 'kilm setup'.[/blue]" - ) - console.print( - "[blue]Use 'kilm status' to check your current configuration.[/blue]" - ) - + outcomes, new_content = _sync_all(libraries, dry_run=dry_run, verbose=verbose) except Exception as e: console.print(f"[red]Error: {e}[/red]") raise typer.Exit(1) from e + _print_summary(outcomes) + # Outside the try: setup reports its own errors through typer.Exit. + _report_new_content(new_content, auto_setup=auto_setup) -# TODO: Should be in services or utils -def check_for_library_changes(lib_path: Path) -> list[str]: - """ - Check if git pull changes indicate new libraries that would require setup. - Uses git diff to analyze what files were added/changed in the pull. - Args: - lib_path: Path to the library directory +def check_for_library_changes(lib_path: Path, since: str = "HEAD~1") -> list[str]: + """Return the kinds of library content added between ``since`` and HEAD. - Returns: - List of change types found ('symbols', 'footprints', 'templates') or empty list if none - """ - changes = [] + The kinds are ``symbols``, ``footprints``, and ``templates``. Added, + modified, and renamed paths + count; deletions do not. When git cannot compare the revisions (for + example, ``since`` predates the first commit), every kind the library + currently contains is returned instead. + Args: + lib_path: The library's git clone. + since: The revision to compare HEAD with, normally HEAD before a pull. + """ try: - # Get the diff between HEAD~1 and HEAD to see what changed in the pull - result = subprocess.run( - ["git", "diff", "--name-status", "HEAD~1", "HEAD"], - cwd=lib_path, - capture_output=True, - text=True, - check=False, + result = _run_git( + lib_path, "diff", "--name-only", "--diff-filter=AMR", since, "HEAD" ) - - if result.returncode != 0: - # If we can't get the diff (e.g., first commit), fall back to checking current state - return _check_current_library_state(lib_path) - - diff_output = result.stdout.strip() - if not diff_output: - return changes - - # Parse the diff output to find relevant changes - for line in diff_output.split("\n"): - if not line.strip(): - continue - - # Parse git diff --name-status format: STATUS\tPATH - parts = line.split("\t", 1) - if len(parts) != 2: - continue - - status, path = parts - # Only consider added (A) or modified (M) files, ignore deletions - if status not in ["A", "M", "R"]: - continue - - # Check for symbol library changes - if _is_symbol_library_change(path) and "symbols" not in changes: - changes.append("symbols") - - # Check for footprint library changes - if _is_footprint_library_change(path) and "footprints" not in changes: - changes.append("footprints") - - # Check for template changes - if _is_template_change(path) and "templates" not in changes: - changes.append("templates") - - except Exception: - # If git diff fails, fall back to checking current state + except OSError: + return _check_current_library_state(lib_path) + if result.returncode != 0: return _check_current_library_state(lib_path) + changes: list[str] = [] + for path in result.stdout.splitlines(): + for kind, matches in _CONTENT_KINDS: + if kind not in changes and matches(path): + changes.append(kind) return changes def _is_symbol_library_change(path: str) -> bool: - """Check if a path change indicates a symbol library change.""" - # Look for .kicad_sym files in symbols directory + """Return whether ``path`` is a symbol library file.""" return path.startswith("symbols/") and path.endswith(".kicad_sym") def _is_footprint_library_change(path: str) -> bool: - """Check if a path change indicates a footprint library change.""" - # Look for .pretty directories or files within them - return (path.startswith("footprints/") and path.endswith(".pretty")) or ( - path.startswith("footprints/") and ".pretty/" in path + """Return whether ``path`` is a footprint library or a file inside one.""" + return path.startswith("footprints/") and ( + path.endswith(".pretty") or ".pretty/" in path ) def _is_template_change(path: str) -> bool: - """Check if a path change indicates a template change.""" - # Look for metadata.yaml files in template directories + """Return whether ``path`` is a project template's metadata file.""" return path.startswith("templates/") and path.endswith("metadata.yaml") +_CONTENT_KINDS = ( + ("symbols", _is_symbol_library_change), + ("footprints", _is_footprint_library_change), + ("templates", _is_template_change), +) + + def _check_current_library_state(lib_path: Path) -> list[str]: - """ - Fallback method to check current library state when git diff is not available. - This is used when we can't determine what changed in the pull. - """ - changes = [] - - # Check for symbol libraries (.kicad_sym files) - symbols_path = lib_path / "symbols" - if ( - symbols_path.exists() - and symbols_path.is_dir() - and any( - f.name.endswith(".kicad_sym") for f in symbols_path.glob("**/*.kicad_sym") - ) - ): + """Return every kind of library content ``lib_path`` currently contains.""" + changes: list[str] = [] + if any((lib_path / "symbols").glob("**/*.kicad_sym")): changes.append("symbols") - - # Check for footprint libraries (.pretty directories) - footprints_path = lib_path / "footprints" - 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 any(path.is_dir() for path in (lib_path / "footprints").glob("**/*.pretty")): changes.append("footprints") - - # Check for project templates (directories with metadata.yaml) - templates_path = lib_path / "templates" - 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 any( + (path / "metadata.yaml").exists() + for path in (lib_path / "templates").glob("*") + if path.is_dir() ): changes.append("templates") - return changes diff --git a/kicad_lib_manager/commands/sync/docs.mdx b/kicad_lib_manager/commands/sync/docs.mdx index 5680ce3..7185774 100644 --- a/kicad_lib_manager/commands/sync/docs.mdx +++ b/kicad_lib_manager/commands/sync/docs.mdx @@ -42,7 +42,7 @@ kilm sync [OPTIONS] 2. **Identifies `github` Libraries:** Filters for libraries with type `github`. 3. **Checks for `.git`:** For each library path, checks if it exists and contains a `.git` directory. 4. **Runs `git pull`:** If it's a valid Git repository, navigates into the directory and executes `git pull` (unless `--dry-run`). -5. **Checks for Changes:** After a successful pull, analyzes the output and file system to detect if new symbol libraries (`.kicad_sym`), footprint libraries (`.pretty`), or templates (`templates/*/metadata.yaml`) were added. +5. **Checks for Changes:** When a pull brings in new commits, compares the library before and after the pull to detect added, changed, or renamed symbol libraries (`.kicad_sym`), footprint libraries (`.pretty`), or templates (`templates/*/metadata.yaml`). 6. **Reports & Optional Setup:** Summarizes the update results. If changes requiring configuration updates were detected, it recommends running `kilm setup` or runs it automatically if `--auto-setup` was specified. ## Examples diff --git a/kicad_lib_manager/commands/template/__init__.py b/kicad_lib_manager/commands/template/__init__.py index 0c1fd19..bac79a9 100644 --- a/kicad_lib_manager/commands/template/__init__.py +++ b/kicad_lib_manager/commands/template/__init__.py @@ -1,22 +1,8 @@ -import typer +"""The ``template`` command group; ``template_app`` is registered by ``main``.""" -from .command import create, list_templates, main_callback, make +from kicad_lib_manager.commands.template.command import template_app +from kicad_lib_manager.commands.template.create import create +from kicad_lib_manager.commands.template.listing import list_templates +from kicad_lib_manager.commands.template.make import make -# Create Typer app with subcommands -template_app = typer.Typer( - name="template", - help="Manage KiCad project templates", - rich_markup_mode="rich", - callback=main_callback, - invoke_without_command=True, - no_args_is_help=True, -) - -# Add subcommands -template_app.command("create", help="Create a new KiCad project from a template")( - create -) -template_app.command("make", help="Create a template from an existing project")(make) -template_app.command("list", help="List all available templates")(list_templates) - -__all__ = ["template_app", "create", "make", "list_templates"] +__all__ = ["create", "list_templates", "make", "template_app"] diff --git a/kicad_lib_manager/commands/template/command.py b/kicad_lib_manager/commands/template/command.py index 660aac2..a6eb286 100644 --- a/kicad_lib_manager/commands/template/command.py +++ b/kicad_lib_manager/commands/template/command.py @@ -1,1084 +1,20 @@ -""" -Template commands implementation for KiCad Library Manager. -Provides commands for creating KiCad projects from templates and creating templates from projects. -""" +"""The ``template`` command group: create, make, and list project templates.""" -import json -import os -import shutil -import sys -import traceback -from pathlib import Path -from typing import Annotated - -import jinja2 -import pathspec -import questionary import typer -import yaml -from rich.console import Console -from rich.panel import Panel -from rich.table import Table - -from ...services.config_service import Config -from ...utils.template import ( - HOOKS_DIR, - POST_CREATE_HOOK, - TEMPLATE_CONTENT_DIR, - TEMPLATE_METADATA, - TEMPLATES_DIR, - TemplateVariable, - create_project_from_template, - create_template_metadata, - create_template_structure, - find_all_templates, - find_potential_variables, - get_gitignore_spec, - render_template_string, -) - -console = Console() -err_console = Console(stderr=True) - - -def main_callback() -> None: - """Manage KiCad project templates. - - This command group allows you to create new KiCad projects from templates, - and create new templates from existing projects. - """ - pass - - -def create( - name: Annotated[ - str | None, typer.Argument(help="Name of the project to create") - ] = None, - directory: Annotated[ - str | None, typer.Argument(help="Directory to create project in") - ] = None, - template: Annotated[ - str | None, typer.Option(help="Name of the template to use") - ] = None, - library: Annotated[ - str | None, typer.Option(help="Name of the library containing the template") - ] = None, - set_var: Annotated[ - list[str] | None, - typer.Option("--set-var", help="Set template variable in key=value format"), - ] = None, - dry_run: Annotated[ - bool, - typer.Option( - "--dry-run", help="Show what would be created without making changes" - ), - ] = False, - skip_hooks: Annotated[ - bool, typer.Option("--skip-hooks", help="Skip post-creation hooks") - ] = False, -) -> None: - """Create a new KiCad project from a template. - - Creates a new KiCad project from a template in one of the configured libraries. - If NAME is provided, it will be used as the project name. If DIRECTORY is provided, - the project will be created in that directory. Otherwise, it will be created - in the current directory. - - If NAME contains a path separator, it will be treated as a directory path - and the name will be extracted from the last part of the path. - - Examples: - - \b - # Create a project named 'MyProject' in the current directory - kilm template create MyProject - - \b - # Create a project in a specific directory - kilm template create MyProject path/to/project - - \b - # Create a project using a full path - kilm template create path/to/project/MyProject - - \b - # Create a project using a specific template - kilm template create MyProject --template basic-project - - \b - # Set template variables - kilm template create MyProject --set-var author="John Doe" --set-var version=1.0 - """ - config = Config() - - # Find all available templates - all_templates = find_all_templates(config) - - if not all_templates: - console.print("No templates found in any configured libraries.") - console.print("Use 'kilm template make' to create a template first.") - return - - # Parse name and directory - if name and os.path.sep in name: - # Name contains a path separator, treat it as a path - path = Path(name) - directory = str(path.parent) - name = path.name - - # If directory is provided but name is not, extract name from directory - if directory and not name: - path = Path(directory) - name = path.name - directory = str(path.parent) - - # Use current directory if not specified - if not directory: - directory = str(Path.cwd()) - - # Convert to Path objects - project_dir = Path(directory) - - # Interactive selection of template if not specified - selected_template = None - if template: - # Use the specified template if it exists - if template in all_templates: - selected_template = all_templates[template] - else: - # Try case-insensitive match - template_lower = template.lower() - for t_name, t_data in all_templates.items(): - if t_name.lower() == template_lower: - selected_template = t_data - break - - if not selected_template: - console.print(f"Template '{template}' not found.") - console.print("Available templates:") - for t_name, t_data in all_templates.items(): - library = t_data.get("source_library", "unknown") - description = t_data.get("description", "") - console.print(f" {t_name} ({library}): {description}") - return - else: - # Interactive template selection using questionary - template_choices = [ - questionary.Choice( - title=f"{t_name} ({t_data.get('source_library', 'unknown')}) - {t_data.get('description', '')}", - value=t_name, # Use template name as the value - ) - for t_name, t_data in all_templates.items() - ] - - selected_template_name = questionary.select( - "Select template:", choices=template_choices, use_shortcuts=True - ).ask() - - if selected_template_name is None: # Handle cancellation - console.print("Template selection cancelled.") - return - - selected_template = all_templates.get(selected_template_name) - - # Ensure selected_template is not None before proceeding - if not selected_template: - console.print("[red]Error: No template selected.[/red]") - return - - # Get template directory - template_path_str = selected_template.get("path") - if not template_path_str: - err_console.print( - f"Error: Template metadata for '{selected_template.get('name')}' is missing the 'path'." - ) - return - template_dir = Path(template_path_str) - - # Load template metadata - metadata_file = template_dir / TEMPLATE_METADATA - try: - with metadata_file.open() as f: - metadata = yaml.safe_load(f) - if not metadata: # Handle empty metadata file - err_console.print( - f"Warning: Template metadata file is empty: {metadata_file}" - ) - metadata = {} # Use empty dict to avoid downstream errors - except Exception as e: - err_console.print(f"Error reading template metadata {metadata_file}: {e}") - return - - # Get template variables from metadata - template_variables = metadata.get("variables", {}) - - # --- Variable Processing --- - variables = {} # Final variables dictionary - command_line_vars = {} # Variables set via --set-var - - # Parse --set-var options first - for var in set_var or []: - if "=" in var: - key, value = var.split("=", 1) - command_line_vars[key.strip()] = value.strip() - - # Set project_name if provided as argument 'name' - if name: - # Only set project_name if it's defined in template or not already set via --set-var - if ( - "project_name" in template_variables - and "project_name" not in command_line_vars - ): - variables["project_name"] = name - elif ( - "project_name" not in template_variables - and "project_name" not in command_line_vars - ): - # If project_name is not an official template var, still use it if provided - variables["project_name"] = name - - # Show template info - console.print() - console.print(f"Using Template: {metadata.get('name', 'Unknown')}") - console.print(f"Description: {metadata.get('description', 'N/A')}") - if metadata.get("use_case"): - console.print(f"Use case: {metadata.get('use_case')}") - - console.print() - console.print("Template Variables:") - - # Combine initial variables from args and --set-var - variables.update(command_line_vars) - - # Sequentially prompt for variables not already provided - for var_name, var_info in template_variables.items(): - if var_name in variables: - # Variable already provided, echo its value - description = var_info.get("description", f"Value for {var_name}") - source = ( - "argument" - if var_name == "project_name" - and name - and "project_name" not in command_line_vars - else "--set-var" - ) - if var_name in command_line_vars or (var_name == "project_name" and name): - console.print( - f" {var_name}: {variables[var_name]} (from {source}) - {description}" - ) - continue # Skip prompting - - # Variable not provided, prompt the user - description = var_info.get("description", f"Value for {var_name}") - default = var_info.get("default", "") - - # Render the default value using already known variables - rendered_default = default - if default and "{{" in default and "}}" in default: - try: - # Use the 'variables' dict which now contains previously entered answers - rendered_default = render_template_string(default, variables) - except jinja2.exceptions.UndefinedError as e: - # If a variable needed for the default hasn't been entered yet, - # keep the original template string or empty if undefined errors occur early - err_console.print( - f"Debug: Undefined variable for default of {var_name}: {e}. Default might be incomplete." - ) - rendered_default = ( - "" # Or keep 'default'? Better to show empty than half-rendered? - ) - except Exception as e: - err_console.print( - f"Warning: Could not render default for {var_name}: {e}" - ) - rendered_default = default # Use original default on other errors - - # Ask the question using questionary.text - answer = questionary.text( - f"{var_name} ({description})", - default=rendered_default, - # Add validation if needed, e.g., lambda text: len(text) > 0 or "Value cannot be empty" - ).ask() - - if answer is None: # Handle Ctrl+C or cancellation - console.print("Variable input cancelled.") - return # Exit the command gracefully - - # Store the answer for use in subsequent default renderings - variables[var_name] = answer - - # --- End Variable Processing --- - - # --- Post-process defaults for dependent variables --- - # Store original defaults that were templates - original_template_defaults = {} - for var_name, var_info in template_variables.items(): - default = var_info.get("default", "") - if default and "{{" in default and "}}" in default: - original_template_defaults[var_name] = default - - # Re-render defaults for variables that might not have been rendered correctly initially - # and update the value if the user accepted the (potentially incorrect) default. - for var_name, original_default_template in original_template_defaults.items(): - # Check if the variable exists in the final variables set - if var_name in variables: - # Calculate the default that was likely *shown* to the user - # (rendered only with prefilled vars available *before* the prompt) - try: - shown_default = render_template_string( - original_default_template, variables - ) - except Exception: - shown_default = ( - original_default_template # Fallback if initial render failed - ) - - # Render the default correctly using *all* collected variables - try: - correct_default = render_template_string( - original_default_template, variables - ) - except Exception as e: - err_console.print( - f"Warning: Could not re-render default for {var_name}: {e}" - ) - correct_default = variables[var_name] # Keep existing value on error - - # If the user's final answer matches the *shown* default (meaning they didn't change it), - # update it with the *correctly* rendered default. - if ( - variables[var_name] == shown_default - and variables[var_name] != correct_default - ): - console.print( - f"Updating default for '{var_name}': '{shown_default}' -> '{correct_default}'" - ) - variables[var_name] = correct_default - # --- End Post-processing --- - - console.print() - console.print( - f"Final variables: {json.dumps(variables, indent=2)}" - ) # Changed message for clarity - - # --- Determine Final Project Directory --- - final_project_dir = None - directory_name_template = template_variables.get("directory_name", {}).get( - "default", "" - ) - directory_name_value = variables.get("directory_name", "") - - # Try using the 'directory_name' variable value if it exists and is not just the template string - if directory_name_value and directory_name_value != directory_name_template: - final_project_dir = project_dir / directory_name_value - elif directory_name_template and "{{" in directory_name_template: - # If value wasn't provided/prompted, try rendering the default template - try: - dir_name_rendered = render_template_string( - directory_name_template, variables - ) - final_project_dir = project_dir / dir_name_rendered - except Exception as e: - err_console.print( - f"Warning: Could not render default directory_name '{directory_name_template}': {e}" - ) - # Fallback below - - # Fallback to using project_name variable if directory_name failed or wasn't conclusive - if not final_project_dir and "project_name" in variables: - project_name_sanitized = variables["project_name"].lower().replace(" ", "-") - final_project_dir = project_dir / project_name_sanitized - console.print(f"Using project_name to determine directory: {final_project_dir}") - - # Final check if we have a directory - if not final_project_dir: - err_console.print( - "Error: Cannot determine project directory. Ensure 'project_name' or 'directory_name' variable is properly handled." - ) - return - # --- End Directory Determination --- - - console.print() - console.print(f"Project will be created in: {final_project_dir}") - - # --- Execution --- - if dry_run: - console.print("Dry run enabled. No changes will be made.") - # Optionally, list files that *would* be created here - else: - # Check if target directory exists and handle overwrite - if final_project_dir.exists(): - if not typer.confirm( - f"Directory '{final_project_dir}' already exists. Overwrite?" - ): - console.print("Aborted.") - return - else: - console.print(f"Removing existing directory: {final_project_dir}") - try: - shutil.rmtree(final_project_dir) - except Exception as e: - err_console.print(f"Error removing existing directory: {e}") - return - - # Create parent directories if they don't exist - try: - final_project_dir.parent.mkdir(parents=True, exist_ok=True) - except Exception as e: - err_console.print( - f"Error creating parent directories for {final_project_dir}: {e}" - ) - return - - # Create project from template using the utility function - try: - # Check if template uses old Jinja2 syntax in filenames and warn Windows users - if os.name == "nt": # Windows - template_content_dir = template_dir / TEMPLATE_CONTENT_DIR - if template_content_dir.exists(): - old_syntax_files = [] - for _root, _dirs, files in os.walk(template_content_dir): - for file in files: - if "{{" in file and "}}" in file: - old_syntax_files.append(file) - - if old_syntax_files: - console.print() - err_console.print("WARNING: Windows Compatibility Notice:") - err_console.print( - "This template uses the old {{variable}} syntax in filenames, which may not work on Windows." - ) - err_console.print( - "Consider updating the template to use the new Windows-compatible %{variable} syntax." - ) - err_console.print("Files with old syntax:") - for file in old_syntax_files[:3]: # Show first 3 files - err_console.print(f" - {file}") - if len(old_syntax_files) > 3: - err_console.print(f" ... and {len(old_syntax_files) - 3} more") - console.print() - err_console.print("New syntax examples:") - err_console.print(" - %{project_name}.kicad_pro") - err_console.print(" - %{project_name.lower}.kicad_sch") - err_console.print(" - %{project_name.replace(' ', '-')}.kicad_pcb") - console.print() - - success = create_project_from_template( - template_dir=template_dir, - project_dir=final_project_dir, - variables=variables, - dry_run=dry_run, - skip_hooks=skip_hooks, - metadata=metadata, # Pass metadata for post-hook if needed - ) - except Exception as e: - err_console.print(f"Error during project creation: {e}") - traceback.print_exc() # Print traceback for debugging - success = False - - if success: - if not dry_run: - console.print() - console.print( - f"Project '{variables.get('project_name', name)}' created successfully in '{final_project_dir}'" - ) - else: - console.print() - err_console.print("Project creation failed.") - - -def make( - name: Annotated[ - str | None, typer.Argument(help="Name of the template to create") - ] = None, - source_directory: Annotated[ - Path | None, typer.Argument(help="Source project directory") - ] = None, - description: Annotated[ - str | None, typer.Option(help="Template description") - ] = None, - use_case: Annotated[ - str | None, typer.Option("--use-case", help="Template use case description") - ] = None, - output_directory: Annotated[ - Path | None, - typer.Option( - "--output-directory", help="Directory where the template will be created" - ), - ] = None, - exclude: Annotated[ - list[str] | None, - typer.Option(help="Additional patterns to exclude (gitignore format)"), - ] = None, - variable: Annotated[ - list[str] | None, - typer.Option(help="Define a template variable in name=value format"), - ] = None, - extends: Annotated[ - str | None, typer.Option(help="Parent template that this template extends") - ] = None, - non_interactive: Annotated[ - bool, - typer.Option( - "--non-interactive", - help="Non-interactive mode (don't prompt for variables or configuration)", - ), - ] = False, - dry_run: Annotated[ - bool, - typer.Option( - "--dry-run", help="Show what would be created without making changes" - ), - ] = False, - force: Annotated[ - bool, typer.Option("--force", help="Overwrite existing template if it exists") - ] = False, -) -> None: - """Create a template from an existing project. - - Creates a new KiCad project template from an existing project. If NAME - is provided, it will be used as the template name. If SOURCE_DIRECTORY is - provided, it will be used as the source project directory. Otherwise, - the current directory will be used. - - By default, the command runs in interactive mode, automatically identifying potential - template variables and prompting for confirmation. Use --non-interactive to disable prompts. - - Examples: - - \b - # Create a template named 'basic-project' from the current directory - kilm template make basic-project - - \b - # Create a template from a specific directory - kilm template make basic-project path/to/project - - \b - # Create a template with a description and use case - kilm template make basic-project --description "Basic KiCad project" \\ - --use-case "Starting point for simple PCB designs" - - \b - # Create a template with variables - kilm template make basic-project --variable "author=John Doe" - - \b - # Create a template without prompts - kilm template make basic-project --non-interactive - - \b - # Create a template that extends another template - kilm template make advanced-project --extends basic-project - """ - # Get available libraries for later use - config = Config() - all_libraries = config.get_libraries( - library_type="github" - ) # Only get GitHub libraries - library_names = [lib["name"] for lib in all_libraries] - library_paths = {lib["name"]: lib["path"] for lib in all_libraries} - - if not library_names: - console.print( - "No GitHub libraries configured. Use 'kilm init' to create one first." - ) - sys.exit(1) - - # Set interactive mode - now the default is True, and --non-interactive makes it False - interactive = not non_interactive - - # If interactive mode, prompt for missing values - if interactive: - # Ask for source directory if not specified - if not source_directory: - default_dir = str(Path.cwd()) - source_dir_input = typer.prompt( - "Source project directory", default=default_dir - ) - # Handle relative paths - if not Path(source_dir_input).is_absolute(): - source_directory = Path.cwd() / source_dir_input - else: - source_directory = Path(source_dir_input) - # Ask for template name if not specified - if not name: - default_name = source_directory.name if source_directory else "template" - name = typer.prompt("Template name", default=default_name) +from kicad_lib_manager.commands.template.create import create +from kicad_lib_manager.commands.template.listing import list_templates +from kicad_lib_manager.commands.template.make import make - # Ask for description and use case if not specified - if not description: - description = typer.prompt( - "Template description", default=f"{name} template" - ) - if not use_case: - use_case = typer.prompt("Template use case", default="") - - # Ask for output directory if not specified - if not output_directory: - # Show numbered list of libraries - console.print("\nChoose a library to store the template:") - for i, lib_name in enumerate(library_names): - console.print(f"{i + 1}. {lib_name} ({library_paths[lib_name]})") - - # Get selection - while True: - try: - lib_selection = typer.prompt( - "Select library (number)", type=int, default=1 - ) - if 1 <= lib_selection <= len(library_names): - selected_lib = library_names[lib_selection - 1] - library_path = library_paths[selected_lib] - break - else: - console.print( - f"Please enter a number between 1 and {len(library_names)}" - ) - except ValueError: - console.print("Please enter a valid number") - - output_directory = Path(library_path) / TEMPLATES_DIR / name - console.print(f"Template will be created in: {output_directory}") - else: - # Non-interactive mode - use current directory if not specified - if not source_directory: - source_directory = Path.cwd() - - # If name is not provided, use the directory name - if not name: - name = source_directory.name - - # Determine the output directory - if not output_directory: - # Find the library to add the template to - library_path = config.get_symbol_library_path() - if not library_path: - console.print( - "No library configured. Use 'kilm init' to create one first." - ) - return - - output_directory = Path(library_path) / TEMPLATES_DIR / name - - # Ensure source_directory is a Path object - if source_directory is None: - source_directory = Path.cwd() - elif not isinstance(source_directory, Path): - source_directory = Path(source_directory) - - # Check if template already exists - if output_directory and output_directory.exists() and not force: - console.print(f"Template '{name}' already exists at {output_directory}") - console.print("Use --force to overwrite.") - return - - # Get gitignore spec - gitignore_spec = get_gitignore_spec(source_directory) - - # Show what we're going to do - console.print(f"Creating template '{name}' from {source_directory}") - console.print(f"Output directory: {output_directory}") - - if description: - console.print(f"Description: {description}") - if use_case: - console.print(f"Use case: {use_case}") - if exclude: - console.print("Additional exclusions:") - for pattern in exclude: - console.print(f" {pattern}") - if extends: - console.print(f"Extends: {extends}") - - # Parse variables from command line - variable_dict: dict[str, TemplateVariable] = {} - for var in variable or []: - if "=" in var: - key, value = var.split("=", 1) - variable_dict[key.strip()] = { - "description": f"Value for {key.strip()}", - "default": value.strip(), - } - - if variable_dict: - console.print("\nTemplate variables:") - for key, value in variable_dict.items(): - console.print(f" {key}: {value['default']} - {value['description']}") - - # If interactive mode is enabled, scan for potential variables - detected_variables: dict[str, TemplateVariable] = {} - if interactive: - potential_vars = find_potential_variables(source_directory) - if potential_vars: - console.print("\nFound potential template variables:") - for var_name, values in potential_vars.items(): - value_str = ", ".join(values) - console.print(f" {var_name}: {value_str}") - - # Ask if the user wants to use this variable - if typer.confirm( - f" Use '{var_name}' as a template variable?", default=True - ): - # Use the first value as default - default_value = values[0] if values else "" - description = typer.prompt( - " Description", default=f"Value for {var_name}" - ) - - detected_variables[var_name] = { - "description": description, - "default": default_value, - } - - # Always ask if the user wants to define additional variables - while typer.confirm( - "Would you like to define additional template variables?", default=False - ): - var_name = typer.prompt("Variable name") - var_default = typer.prompt("Default value", default="") - var_description = typer.prompt( - "Description", default=f"Value for {var_name}" - ) - - detected_variables[var_name] = { - "description": var_description, - "default": var_default, - } - - # Merge manual and detected variables, with manual taking precedence - variables = {**detected_variables, **variable_dict} - - # Create metadata - metadata = create_template_metadata( - name=name, - description=description, - use_case=use_case, - variables=variables, - extends=extends, - dependencies=None, - ) - - # Preview what will be included - if dry_run: - # Get the list of files that would be included - included_files = [] - excluded_files = [] - - additional_spec = None - if exclude: - additional_spec = pathspec.PathSpec.from_lines("gitwildmatch", exclude) - - for root, dirs, files in os.walk(source_directory): - rel_root = os.path.relpath(root, source_directory) - if rel_root == ".": - rel_root = "" - - # Skip directories that should be excluded - dirs_to_remove = [] - for d in dirs: - rel_path = str(Path(rel_root) / d) if rel_root else d - # Ensure proper gitignore path format for directories - git_path = rel_path.replace(os.sep, "/") - 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) - ): - dirs_to_remove.append(d) - excluded_files.append(f"{rel_path}/") - - for d in dirs_to_remove: - dirs.remove(d) - - # Check files - for file in files: - rel_path = str(Path(rel_root) / file) if rel_root else file - # Ensure proper gitignore path format - git_path = rel_path.replace(os.sep, "/") - - # Skip gitignored files and additional excluded files - if gitignore_spec and gitignore_spec.match_file(git_path): - excluded_files.append(rel_path) - continue - if additional_spec and additional_spec.match_file(git_path): - excluded_files.append(rel_path) - continue - - included_files.append(rel_path) - - # Sort and display - included_files.sort() - excluded_files.sort() - - console.print("\nFiles that will be included in the template:") - for file in included_files: - console.print(f" + {file}") - - # Show which Markdown files will be templated - md_files = [f for f in included_files if f.lower().endswith(".md")] - if md_files: - console.print("\nMarkdown files that will be converted to Jinja templates:") - for file in md_files: - console.print(f" * {file}") - - # Show which KiCad project files will be templated - kicad_files = [ - f - for f in included_files - if f.lower().endswith((".kicad_pro", ".kicad_sch", ".kicad_pcb")) - ] - if kicad_files: - console.print("\nKiCad project files that will be templated:") - for file in kicad_files: - # Show the templated filename that will be used - if file.lower().endswith(".kicad_pro"): - templated_name = "%{project_filename}.kicad_pro" - elif file.lower().endswith(".kicad_sch"): - templated_name = "%{project_filename}.kicad_sch" - elif file.lower().endswith(".kicad_pcb"): - templated_name = "%{project_filename}.kicad_pcb" - else: - templated_name = file - - console.print(f" * {file} → {templated_name}") - - console.print("\nFiles that will be excluded from the template:") - for file in excluded_files: - console.print(f" - {file}") - - # If this is a dry run, stop here - if dry_run: - console.print("\nDry run complete. No changes were made.") - return - - # Create the template - try: - # Create the template directory structure - if output_directory: - output_directory.mkdir(parents=True, exist_ok=True) - - # Create template structure with special handling for Markdown files - create_template_structure( - source_directory=source_directory, - template_directory=output_directory, - metadata=metadata, - gitignore_spec=gitignore_spec, - additional_excludes=exclude or None, - ) - - console.print(f"\nTemplate '{name}' created successfully at {output_directory}") - - # Add hints for next steps - console.print("\nNext steps:") - console.print( - f"1. Edit {output_directory / TEMPLATE_METADATA if output_directory else 'N/A'} to customize template metadata" - ) - console.print( - f"2. Customize template files in {output_directory / TEMPLATE_CONTENT_DIR if output_directory else 'N/A'}" - ) - console.print( - f"3. Edit post-creation hook in {output_directory / HOOKS_DIR / POST_CREATE_HOOK if output_directory else 'N/A'} if needed" - ) - console.print( - f"4. Use your template with: kilm template create MyProject --template {name}" - ) - - # Add information about filename templating syntax - console.print("\nFilename Templating:") - console.print("For Windows compatibility, use %{variable} syntax in filenames:") - console.print(" - %{project_name}.kicad_pro") - console.print(" - %{project_name.lower}.kicad_sch") - console.print(" - %{project_name.replace(' ', '-')}.kicad_pcb") - console.print(" - %{project_name.upper.replace(' ', '_')}.md") - console.print( - "(Old {{variable}} syntax still works but may cause issues on Windows)" - ) - - except Exception as e: - err_console.print(f"Error creating template: {str(e)}") - traceback.print_exc() - sys.exit(1) - - -def list_templates( - library: Annotated[ - str | None, typer.Option(help="Filter templates by library name") - ] = None, - verbose: Annotated[ - bool, - typer.Option( - "-v", "--verbose", help="Show detailed information including variables" - ), - ] = False, - json_output: Annotated[ - bool, typer.Option("--json", help="Output in JSON format") - ] = False, -) -> None: - """List all available templates. - - Displays all available templates across all configured libraries, - with their descriptions, source libraries, and other metadata. - - Examples: - - \b - # List all templates - kilm template list - - \b - # List templates with detailed information - kilm template list --verbose - - \b - # List templates from a specific library - kilm template list --library my-library - - \b - # Output template list in JSON format - kilm template list --json - """ - config = Config() - - # Find all available templates - all_templates = find_all_templates(config) - - if not all_templates: - console.print() - console.print( - Panel( - "[yellow]No templates found in any configured libraries.[/yellow]\n\n" - "[cyan]💡 Get Started:[/cyan]\n" - "• Create a template: [blue]kilm template make[/blue]\n" - "• Check library configuration: [blue]kilm list-libraries[/blue]", - title="[bold yellow]⚠️ No Templates[/bold yellow]", - border_style="yellow", - ) - ) - return - - # Filter by library if requested - if library: - all_templates = { - name: data - for name, data in all_templates.items() - if data.get("source_library", "").lower() == library.lower() - } - - if not all_templates: - console.print() - console.print( - Panel( - f"[yellow]No templates found in library '[cyan]{library}[/cyan]'.[/yellow]\n\n" - "[cyan]💡 Try:[/cyan]\n" - f"• List all libraries: [blue]kilm list-libraries[/blue]\n" - f"• Create template in '{library}': [blue]kilm template make --library {library}[/blue]\n" - "• List all templates: [blue]kilm template list[/blue]", - title="[bold yellow]⚠️ No Templates Found[/bold yellow]", - border_style="yellow", - ) - ) - return - - # If JSON output is requested - if json_output: - import json as json_lib - - console.print(json_lib.dumps(all_templates, indent=2)) - return - - # Group templates by library for display - templates_by_library = {} - for name, data in all_templates.items(): - lib_name = data.get("source_library", "Unknown") - if lib_name not in templates_by_library: - templates_by_library[lib_name] = [] - templates_by_library[lib_name].append((name, data)) - - # Display templates in Rich tables organized by library - console.print() - - for lib_name, templates in templates_by_library.items(): - # Create a table for each library - table = Table( - title=f"[bold cyan]{lib_name}[/bold cyan] Templates", - show_header=True, - header_style="bold magenta", - border_style="cyan", - ) - - table.add_column("Template", style="cyan", no_wrap=True) - table.add_column("Version", justify="center", style="blue", width=8) - table.add_column("Description", style="green") - - if verbose: - table.add_column("Variables", style="yellow", max_width=30) - table.add_column("Extends", style="magenta", max_width=15) - - # Add rows for each template - for name, data in sorted(templates): - description = data.get("description", "No description") - version = data.get("version", "1.0.0") - extends = data.get("extends", "") - - row_data = [f"[bold]{name}[/bold]", version, description] - - if verbose: - # Format variables for display - variables = data.get("variables", {}) - var_display = "" - if variables: - var_list = [f"{k}" for k in variables] - var_display = ", ".join(var_list[:3]) # Show first 3 variables - if len(var_list) > 3: - var_display += f", +{len(var_list) - 3} more" - else: - var_display = "[dim]None[/dim]" - - row_data.append(var_display) - row_data.append(extends if extends else "[dim]None[/dim]") - - table.add_row(*row_data) - - console.print(table) - console.print() - - # Add usage hint panel - hint_content = ( - "[cyan]Usage:[/cyan] kilm template create --template \n" - ) - hint_content += "[cyan]Verbose:[/cyan] kilm template list --verbose\n" - hint_content += "[cyan]Filter:[/cyan] kilm template list --library " - - console.print( - Panel( - hint_content, - title="[bold blue]💡 Quick Tips[/bold blue]", - border_style="blue", - ) - ) - # Add summary panel - library_count = len(templates_by_library) - template_count = len(all_templates) - - summary_content = f"[green]Libraries:[/green] {library_count}\n" - summary_content += f"[green]Templates:[/green] {template_count}" - - console.print( - Panel( - summary_content, - title="[bold cyan]📊 Summary[/bold cyan]", - border_style="cyan", - width=30, - ) - ) - - -# Register the template command -if __name__ == "__main__": - main_callback() +template_app = typer.Typer( + name="template", + help="Manage KiCad project templates", + rich_markup_mode="rich", + invoke_without_command=True, + no_args_is_help=True, +) +template_app.command("create", help="Create a new KiCad project from a template")( + create +) +template_app.command("make", help="Create a template from an existing project")(make) +template_app.command("list", help="List all available templates")(list_templates) diff --git a/kicad_lib_manager/commands/template/create.py b/kicad_lib_manager/commands/template/create.py new file mode 100644 index 0000000..eba0b6a --- /dev/null +++ b/kicad_lib_manager/commands/template/create.py @@ -0,0 +1,333 @@ +"""The ``template create`` command: a new project from a template.""" + +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"questionary"} + +import json +import os +import shutil +from pathlib import Path +from typing import Annotated + +import questionary +import typer + +from kicad_lib_manager.commands.shared import parse_assignments +from kicad_lib_manager.services.config_service import Config +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import TEMPLATE_CONTENT_DIR +from kicad_lib_manager.utils.template.discovery import find_all_templates +from kicad_lib_manager.utils.template.models import ( + DiscoveredTemplate, + TemplateVariable, +) +from kicad_lib_manager.utils.template.project import create_project_from_template +from kicad_lib_manager.utils.template.rendering import ( + contains_template_syntax, + render_template_string, +) + +PROJECT_NAME = "project_name" +DIRECTORY_NAME = "directory_name" +MAX_LISTED_OLD_SYNTAX_FILES = 3 + + +def create( # noqa: PLR0913 -- Typer maps each CLI option to one parameter + name: Annotated[ + str | None, typer.Argument(help="Name of the project to create") + ] = None, + directory: Annotated[ + str | None, typer.Argument(help="Directory to create project in") + ] = None, + *, + template: Annotated[ + str | None, typer.Option(help="Name of the template to use") + ] = None, + library: Annotated[ + str | None, typer.Option(help="Name of the library containing the template") + ] = None, + set_var: Annotated[ + list[str] | None, + typer.Option("--set-var", help="Set template variable in key=value format"), + ] = None, + dry_run: Annotated[ + bool, + typer.Option( + "--dry-run", help="Show what would be created without making changes" + ), + ] = False, + skip_hooks: Annotated[ + bool, typer.Option("--skip-hooks", help="Skip post-creation hooks") + ] = False, +) -> None: + """Create a KiCad project from a template in one of the configured libraries. + + The project goes into a subdirectory of DIRECTORY (default: the working + directory), named by the ``directory_name`` variable or derived from the + project name. A NAME with a path separator gives both the parent directory + and the name. Variables not set by NAME or ``--set-var`` are prompted for. + """ + templates = find_all_templates(Config(), library) + if not templates: + where = ( + "any configured libraries" if library is None else f"library '{library}'" + ) + get_console().print(f"No templates found in {where}.") + get_console().print("Use 'kilm template make' to create a template first.") + return + + name, base_directory = _split_name_and_directory(name, directory) + selected = _select_template(templates, template) + if selected is None: + return + variables = _collect_variables(selected, name, set_var or []) + if variables is None: + return + get_console().print() + get_console().print(f"Final variables: {json.dumps(variables, indent=2)}") + + project_dir = _target_directory( + base_directory, selected, variables, dry_run=dry_run + ) + if project_dir is None: + return + if os.name == "nt": + _warn_old_filename_syntax(Path(selected["path"]) / TEMPLATE_CONTENT_DIR) + try: + success = create_project_from_template( + selected, project_dir, variables, dry_run=dry_run, skip_hooks=skip_hooks + ) + except OSError as error: + get_console(stderr=True).print(f"Error during project creation: {error}") + success = False + if not success: + get_console().print() + get_console(stderr=True).print("Project creation failed.") + elif not dry_run: + get_console().print() + get_console().print( + f"Project '{variables.get(PROJECT_NAME, name)}' created successfully" + f" in '{project_dir}'" + ) + + +def _split_name_and_directory( + name: str | None, directory: str | None +) -> tuple[str | None, Path]: + """Resolve the NAME and DIRECTORY arguments into a name and a parent directory. + + A NAME with a path separator is split into its parent and last part; a + DIRECTORY without a NAME is split the same way. The parent defaults to + the working directory. + """ + if name is not None and os.path.sep in name: + path = Path(name) + directory, name = str(path.parent), path.name + if directory is not None and directory and (name is None or not name): + path = Path(directory) + name, directory = path.name, str(path.parent) + if directory is None or not directory: + return name, Path.cwd() + return name, Path(directory) + + +def _select_template( + templates: dict[str, DiscoveredTemplate], requested: str | None +) -> DiscoveredTemplate | None: + """Return the requested template, matched case-insensitively, or ask for one. + + Returns None, after explaining why, when the template does not exist or + the selection is cancelled. + """ + if requested is None: + choices = [ + questionary.Choice(title=_describe(name, data), value=name) + for name, data in templates.items() + ] + answer: object = questionary.select( + "Select template:", choices=choices, use_shortcuts=True + ).ask() + if not isinstance(answer, str): + get_console().print("Template selection cancelled.") + return None + return templates[answer] + if requested in templates: + return templates[requested] + wanted = requested.lower() + for name, data in templates.items(): + if name.lower() == wanted: + return data + get_console().print(f"Template '{requested}' not found.") + get_console().print("Available templates:") + for name, data in templates.items(): + get_console().print( + f" {name} ({data['source_library']}): {data.get('description', '')}" + ) + return None + + +def _describe(name: str, data: DiscoveredTemplate) -> str: + return f"{name} ({data['source_library']}) - {data.get('description', '')}" + + +def _collect_variables( + template: DiscoveredTemplate, name: str | None, assignments: list[str] +) -> dict[str, str] | None: + """Gather the variable values, prompting for each one not given. + + NAME sets ``project_name`` unless ``--set-var`` does. A prompt's default is + rendered with the values gathered before it. Returns None when a prompt + is cancelled. + """ + from_options = parse_assignments(assignments) + variables: dict[str, str] = {} + name_given = name is not None and bool(name) and PROJECT_NAME not in from_options + if name is not None and name_given: + variables[PROJECT_NAME] = name + variables.update(from_options) + + console = get_console() + console.print() + console.print(f"Using Template: {template['name']}") + console.print(f"Description: {template.get('description', 'N/A')}") + use_case = template.get("use_case", "") + if use_case: + console.print(f"Use case: {use_case}") + console.print() + console.print("Template Variables:") + + for var_name, info in template.get("variables", {}).items(): + description = info["description"] + if var_name in variables: + source = ( + "argument" if var_name == PROJECT_NAME and name_given else "--set-var" + ) + console.print( + f" {var_name}: {variables[var_name]} (from {source}) - {description}" + ) + continue + default = info["default"] + if contains_template_syntax(default): + default = render_template_string(default, variables) + answer: object = questionary.text( + f"{var_name} ({description})", default=default + ).ask() + if not isinstance(answer, str): + console.print("Variable input cancelled.") + return None + variables[var_name] = answer + return variables + + +def _target_directory( + base: Path, + template: DiscoveredTemplate, + variables: dict[str, str], + *, + dry_run: bool, +) -> Path | None: + """Announce the project directory and, unless dry-running, prepare it. + + Returns None, after explaining why, when there is no directory to use. + """ + project_dir = _project_directory(base, template.get("variables", {}), variables) + if project_dir is None: + get_console(stderr=True).print( + "Error: Cannot determine project directory. Ensure 'project_name' or" + " 'directory_name' variable is properly handled." + ) + return None + get_console().print() + get_console().print(f"Project will be created in: {project_dir}") + if dry_run: + get_console().print("Dry run enabled. No changes will be made.") + elif not _prepare_project_directory(project_dir): + return None + return project_dir + + +def _project_directory( + base: Path, + template_variables: dict[str, TemplateVariable], + variables: dict[str, str], +) -> Path | None: + """Choose the project directory inside ``base``. + + Uses ``directory_name`` when it differs from its unrendered default, else + the rendered Jinja default, else ``project_name`` lower-cased with dashes. + """ + declared = template_variables.get(DIRECTORY_NAME) + template_default = "" if declared is None else declared["default"] + value = variables.get(DIRECTORY_NAME, "") + if value and value != template_default: + return base / value + if "{{" in template_default: + return base / render_template_string(template_default, variables) + project_name = variables.get(PROJECT_NAME) + if project_name is None: + return None + project_dir = base / project_name.lower().replace(" ", "-") + get_console().print(f"Using project_name to determine directory: {project_dir}") + return project_dir + + +def _prepare_project_directory(project_dir: Path) -> bool: + """Remove an existing project directory, after confirmation; create its parents. + + Returns False, after explaining why, when the user declines or the + directories cannot be changed. + """ + if project_dir.exists(): + if not typer.confirm(f"Directory '{project_dir}' already exists. Overwrite?"): + get_console().print("Aborted.") + return False + get_console().print(f"Removing existing directory: {project_dir}") + try: + shutil.rmtree(project_dir) + except OSError as error: + get_console(stderr=True).print( + f"Error removing existing directory: {error}" + ) + return False + try: + project_dir.parent.mkdir(parents=True, exist_ok=True) + except OSError as error: + get_console(stderr=True).print( + f"Error creating parent directories for {project_dir}: {error}" + ) + return False + return True + + +def _warn_old_filename_syntax(content_dir: Path) -> None: + """Warn on Windows when template file names use ``{{variable}}`` syntax.""" + old_syntax = [ + file.name + for file in content_dir.rglob("*") + if file.is_file() and "{{" in file.name and "}}" in file.name + ] + if not old_syntax: + return + console = get_console() + errors = get_console(stderr=True) + console.print() + errors.print("WARNING: Windows Compatibility Notice:") + errors.print( + "This template uses the old {{variable}} syntax in filenames, which may" + " not work on Windows." + ) + errors.print( + "Consider updating the template to use the new Windows-compatible" + " %{variable} syntax." + ) + errors.print("Files with old syntax:") + for file_name in old_syntax[:MAX_LISTED_OLD_SYNTAX_FILES]: + errors.print(f" - {file_name}") + if len(old_syntax) > MAX_LISTED_OLD_SYNTAX_FILES: + errors.print(f" ... and {len(old_syntax) - MAX_LISTED_OLD_SYNTAX_FILES} more") + console.print() + errors.print("New syntax examples:") + errors.print(" - %{project_name}.kicad_pro") + errors.print(" - %{project_name.lower}.kicad_sch") + errors.print(" - %{project_name.replace(' ', '-')}.kicad_pcb") + console.print() diff --git a/kicad_lib_manager/commands/template/docs.mdx b/kicad_lib_manager/commands/template/docs.mdx index a73ecfe..a45ed63 100644 --- a/kicad_lib_manager/commands/template/docs.mdx +++ b/kicad_lib_manager/commands/template/docs.mdx @@ -19,10 +19,10 @@ Creates a new project template from an existing KiCad project directory. #### Usage ```bash -kilm template make [project_path] [OPTIONS] +kilm template make [template_name] [project_path] [OPTIONS] ``` -- ``: **Required.** The name for the new template. +- `[template_name]`: The name for the new template. Defaults to the name of the source directory (offered as the default when prompting). - `[project_path]`: The path to the KiCad project directory to use as the source. Defaults to the current directory. #### Options @@ -33,8 +33,8 @@ kilm template make [project_path] [OPTIONS] - `--extends TEXT`: Name of a parent template that this template extends. - `--exclude TEXT`: Glob pattern (like `.gitignore`) to exclude specific files or directories from the template. Can be used multiple times. - `--output-directory DIRECTORY`: - Specifies the directory where the `templates/` structure will be created. Defaults to the `templates` directory within the KiLM library containing the current working directory. - _Example:_ `kilm template make my-tmpl --output-directory /path/to/central/templates/dir` + The template directory to create; the template files go directly into it. Defaults to `templates/` in the library you choose when prompted, or in the configured symbol library with `--non-interactive`. + _Example:_ `kilm template make my-tmpl --output-directory /path/to/central/templates/my-tmpl` - `--non-interactive`: Create the template without interactive prompts for configuration or variable detection. Default: `False` (interactive). - `--dry-run`: @@ -46,12 +46,12 @@ kilm template make [project_path] [OPTIONS] #### Behavior 1. **Scans Project:** Analyzes the source project directory. -2. **Determines Output:** Finds the target `templates/` directory (either default or via `--output-directory`). -3. **Creates Template Structure:** Creates `templates//`. -4. **Copies Files:** Copies project files into `templates//template/`, respecting `.gitignore` and `--exclude` patterns. +2. **Determines Output:** Uses `--output-directory`, or `templates/` in the chosen library. +3. **Creates Template Structure:** Creates the template directory. +4. **Copies Files:** Copies project files into its `template/` directory, respecting `.gitignore` and `--exclude` patterns. 5. **Filename Templating:** Renames files using Windows-compatible syntax based on detected project names and provided/detected variables (e.g., `MyProject.kicad_pro` becomes `%{project_filename}.kicad_pro.jinja2`). -6. **Creates `metadata.yaml`:** Generates a `metadata.yaml` file containing the template name, description, use case, detected/defined variables, and exclusions. -7. **(Optional) Creates Hooks:** Can set up pre/post-creation hook scripts (e.g., `hooks/post_create.py`). +6. **Creates `metadata.yaml`:** Generates a `metadata.yaml` file containing the template name, description, use case, version, detected/defined variables, and the `--extends` parent. +7. **Creates a Hook:** Writes a starter `hooks/post_create.py` to edit or delete. #### Examples @@ -81,11 +81,11 @@ Creates a new KiCad project based on an existing template. #### Usage ```bash -kilm template create [output_path] [OPTIONS] +kilm template create [project_name_or_path] [output_path] [OPTIONS] ``` -- ``: **Required.** The name for the new KiCad project. If this contains path separators (e.g., `path/to/MyNewBoard`), the last component is used as the project name, and the preceding path is used as the default `output_path`. -- `[output_path]`: The directory where the new project will be created. If omitted, defaults to a directory named `` in the current location, or the path derived from ``. +- `[project_name_or_path]`: The name for the new KiCad project; prompted for when omitted. If this contains path separators (e.g., `path/to/MyNewBoard`), the last component is used as the project name, and the preceding path is used as the default `output_path`. +- `[output_path]`: The parent directory of the new project; defaults to the current directory or the path derived from ``. The project directory inside it is named by the template's `directory_name` variable, or by the project name in lower case with spaces replaced by dashes. #### Options @@ -108,7 +108,7 @@ kilm template create [output_path] [OPTIONS] 5. **Processes Templates:** - Renders filenames using Windows-compatible `%{variable}` syntax (with fallback support for legacy `{{variable}}` syntax) - Renders file content using Jinja2 `{{variable}}` syntax for `.jinja2` files -6. **(Optional) Runs Hooks:** Executes the `hooks/post_create.py` script if it exists and `--skip-hooks` was not used. +6. **(Optional) Runs Hooks:** Executes the `hooks/post_create.py` script if it exists and `--skip-hooks` was not used. Its `post_create(context)` receives `project_dir`, `variables`, and `template`: the template's metadata, including any keys of your own in `metadata.yaml`, with each key as YAML loads it (`7` and `'7'` stay distinct). #### Examples @@ -147,7 +147,7 @@ kilm template list [OPTIONS] #### Options - `--library TEXT`: List templates only from the specified KiLM library name. -- `-v, --verbose`: Show detailed information, including template description, use case, and variables. +- `-v, --verbose`: Add the template variables and the parent template (`extends`) to the table. - `--json`: Output the list in JSON format. - `--help`: Show help message. @@ -194,7 +194,7 @@ your-kilm-library/ ├── %{project_filename}.kicad_pcb.jinja2 ├── README.md.jinja2 └── assets/ - └── logo-%{company_name|lower}.png.jinja2 + └── logo-%{company_name.lower}.png.jinja2 ``` - **`metadata.yaml`**: Defines template variables and descriptions. diff --git a/kicad_lib_manager/commands/template/listing.py b/kicad_lib_manager/commands/template/listing.py new file mode 100644 index 0000000..ac75a5c --- /dev/null +++ b/kicad_lib_manager/commands/template/listing.py @@ -0,0 +1,163 @@ +"""The ``template list`` command.""" + +import json +from collections.abc import Mapping +from typing import Annotated, cast + +import typer +from rich.panel import Panel +from rich.table import Table + +from kicad_lib_manager.services.config_service import Config +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.template.discovery import find_all_templates +from kicad_lib_manager.utils.template.models import DiscoveredTemplate + +MAX_LISTED_VARIABLES = 3 +NONE_MARKUP = "[dim]None[/dim]" + + +def list_templates( + library: Annotated[ + str | None, typer.Option(help="Filter templates by library name") + ] = None, + *, + verbose: Annotated[ + bool, + typer.Option( + "-v", "--verbose", help="Show detailed information including variables" + ), + ] = False, + json_output: Annotated[ + bool, typer.Option("--json", help="Output in JSON format") + ] = False, +) -> None: + """List the templates of the configured libraries, grouped by library. + + ``--json`` prints the discovered templates as JSON on stdout instead. + """ + templates = find_all_templates(Config(), library) + if not templates: + _print_no_templates(library) + return + if json_output: + # Plain echo: a Rich console would wrap long values and break the JSON. + # Custom keys may hold YAML dates and such, which print as text. + typer.echo(json.dumps(_json_keys(templates), indent=2, default=str)) + return + + by_library: dict[str, list[tuple[str, DiscoveredTemplate]]] = {} + for name, data in templates.items(): + by_library.setdefault(data["source_library"], []).append((name, data)) + console = get_console() + console.print() + for library_name, library_templates in by_library.items(): + console.print(_library_table(library_name, library_templates, verbose=verbose)) + console.print() + console.print( + Panel( + "[cyan]Usage:[/cyan] kilm template create --template" + " \n" + "[cyan]Verbose:[/cyan] kilm template list --verbose\n" + "[cyan]Filter:[/cyan] kilm template list --library ", + title="[bold blue]Quick Tips[/bold blue]", + border_style="blue", + ) + ) + console.print( + Panel( + f"[green]Libraries:[/green] {len(by_library)}\n" + f"[green]Templates:[/green] {len(templates)}", + title="[bold cyan]Summary[/bold cyan]", + border_style="cyan", + width=30, + ) + ) + + +def _print_no_templates(library: str | None) -> None: + """Explain that there are no templates and what to do about it.""" + get_console().print() + if library is not None: + panel = Panel( + f"[yellow]No templates found in library '[cyan]{library}[/cyan]'." + "[/yellow]\n\n" + "[cyan]Try:[/cyan]\n" + "• List all libraries: [blue]kilm config list[/blue]\n" + "• Create a template: [blue]kilm template make[/blue]\n" + "• List all templates: [blue]kilm template list[/blue]", + title="[bold yellow]No Templates Found[/bold yellow]", + border_style="yellow", + ) + else: + panel = Panel( + "[yellow]No templates found in any configured libraries.[/yellow]\n\n" + "[cyan]Get Started:[/cyan]\n" + "• Create a template: [blue]kilm template make[/blue]\n" + "• Check library configuration: [blue]kilm config list[/blue]", + title="[bold yellow]No Templates[/bold yellow]", + border_style="yellow", + ) + get_console().print(panel) + + +def _library_table( + library_name: str, + templates: list[tuple[str, DiscoveredTemplate]], + *, + verbose: bool, +) -> Table: + """Build the table of one library's templates, sorted by name.""" + table = Table( + title=f"[bold cyan]{library_name}[/bold cyan] Templates", + show_header=True, + header_style="bold magenta", + border_style="cyan", + ) + table.add_column("Template", style="cyan", no_wrap=True) + table.add_column("Version", justify="center", style="blue", width=8) + table.add_column("Description", style="green") + if verbose: + table.add_column("Variables", style="yellow", max_width=30) + table.add_column("Extends", style="magenta", max_width=15) + for name, data in sorted(templates, key=lambda item: item[0]): + row = [ + f"[bold]{name}[/bold]", + data.get("version", "1.0.0"), + data.get("description", "No description"), + ] + if verbose: + extends = data.get("extends", "") + row += [_variables_summary(data), extends or NONE_MARKUP] + table.add_row(*row) + return table + + +def _variables_summary(template: DiscoveredTemplate) -> str: + """Name the first variables of a template and count the rest.""" + names = list(template.get("variables", {})) + if not names: + return NONE_MARKUP + summary = ", ".join(names[:MAX_LISTED_VARIABLES]) + if len(names) > MAX_LISTED_VARIABLES: + summary += f", +{len(names) - MAX_LISTED_VARIABLES} more" + return summary + + +def _json_keys(value: object) -> object: + """Return ``value`` with the mapping keys JSON cannot hold turned into text. + + JSON object keys are text; json.dumps also accepts numbers, booleans, and + None but fails on other keys, such as the dates YAML loads. + """ + if isinstance(value, Mapping): + items = cast("Mapping[object, object]", value).items() + return { + key if isinstance(key, (str, int, float)) or key is None else str(key): ( + _json_keys(item) + ) + for key, item in items + } + if isinstance(value, list): + return [_json_keys(item) for item in cast("list[object]", value)] + return value diff --git a/kicad_lib_manager/commands/template/make.py b/kicad_lib_manager/commands/template/make.py new file mode 100644 index 0000000..7a251ba --- /dev/null +++ b/kicad_lib_manager/commands/template/make.py @@ -0,0 +1,350 @@ +"""The ``template make`` command: a template from an existing project.""" + +from dataclasses import dataclass +from pathlib import Path +from typing import Annotated + +import typer + +from kicad_lib_manager.commands.shared import parse_assignments +from kicad_lib_manager.services.config_service import Config, LibraryDict +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import ( + HOOKS_DIR, + TEMPLATE_CONTENT_DIR, + TEMPLATE_METADATA, + TEMPLATES_DIR, +) +from kicad_lib_manager.utils.template.authoring import ( + create_template_metadata, + create_template_structure, +) +from kicad_lib_manager.utils.template.constants import ( + MARKDOWN_EXT, + POST_CREATE_HOOK, + PROJECT_FILENAME_VARIABLE, +) +from kicad_lib_manager.utils.template.models import ( + TemplateVariable, + default_description, +) +from kicad_lib_manager.utils.template.selection import ( + TemplatePlan, + find_potential_variables, + get_gitignore_spec, + plan_template_files, +) + +GITHUB_LIBRARY = "github" + + +@dataclass(frozen=True) +class TemplateTarget: + """Where a new template comes from and goes to, with its descriptions.""" + + name: str + source: Path + output: Path + description: str | None + use_case: str | None + + +def make( # noqa: PLR0913 -- Typer maps each CLI option to one parameter + name: Annotated[ + str | None, typer.Argument(help="Name of the template to create") + ] = None, + source_directory: Annotated[ + Path | None, typer.Argument(help="Source project directory") + ] = None, + *, + description: Annotated[ + str | None, typer.Option(help="Template description") + ] = None, + use_case: Annotated[ + str | None, typer.Option("--use-case", help="Template use case description") + ] = None, + output_directory: Annotated[ + Path | None, + typer.Option( + "--output-directory", help="Directory where the template will be created" + ), + ] = None, + exclude: Annotated[ + list[str] | None, + typer.Option(help="Additional patterns to exclude (gitignore format)"), + ] = None, + variable: Annotated[ + list[str] | None, + typer.Option(help="Define a template variable in name=value format"), + ] = None, + extends: Annotated[ + str | None, typer.Option(help="Parent template that this template extends") + ] = None, + non_interactive: Annotated[ + bool, + typer.Option( + "--non-interactive", + help="Non-interactive mode (don't prompt for variables or configuration)", + ), + ] = False, + dry_run: Annotated[ + bool, + typer.Option( + "--dry-run", help="Show what would be created without making changes" + ), + ] = False, + force: Annotated[ + bool, typer.Option("--force", help="Overwrite existing template if it exists") + ] = False, +) -> None: + """Create a template from an existing KiCad project. + + Interactively, asks for each part not given (source, name, description, + use case, target library) and offers values found in the project as + variables. With ``--non-interactive``, the name defaults to the source + directory's and the output to ``templates/`` in the symbol library. + Exits with status 1 when no GitHub library is configured. + """ + config = Config() + libraries = config.get_libraries(library_type=GITHUB_LIBRARY) + if not libraries: + get_console().print( + "No GitHub libraries configured. Use 'kilm init' to create one first." + ) + raise typer.Exit(1) + + requested = TemplateTarget( + name="" if name is None else name, + source=Path.cwd() if source_directory is None else source_directory, + output=Path() if output_directory is None else output_directory, + description=description, + use_case=use_case, + ) + if non_interactive: + target = _complete_without_prompts( + config, requested, has_output=output_directory is not None + ) + else: + target = _complete_with_prompts( + libraries, + requested, + has_source=source_directory is not None, + has_output=output_directory is not None, + ) + if target is None: + return + if target.output.exists() and not force: + get_console().print( + f"Template '{target.name}' already exists at {target.output}" + ) + get_console().print("Use --force to overwrite.") + return + + excludes = exclude or [] + _print_request(target, excludes, extends) + variables = _parse_variables(variable or []) + if not non_interactive: + variables = {**_ask_variables(target.source), **variables} + metadata = create_template_metadata( + name=target.name, + description=target.description, + use_case=target.use_case, + variables=variables, + extends=extends, + ) + gitignore_spec = get_gitignore_spec(target.source) + if dry_run: + _print_plan( + plan_template_files( + target.source, + template_directory=target.output, + gitignore_spec=gitignore_spec, + additional_excludes=excludes or None, + ) + ) + get_console().print("\nDry run complete. No changes were made.") + return + try: + create_template_structure( + target.source, + target.output, + metadata, + gitignore_spec=gitignore_spec, + additional_excludes=excludes or None, + ) + except OSError as error: + get_console(stderr=True).print(f"Error creating template: {error!s}") + raise typer.Exit(1) from error + _print_next_steps(target) + + +def _complete_with_prompts( + libraries: list[LibraryDict], + requested: TemplateTarget, + *, + has_source: bool, + has_output: bool, +) -> TemplateTarget: + """Ask for each part of the target that was not given on the command line.""" + source = requested.source + if not has_source: + answer = Path( + str(typer.prompt("Source project directory", default=str(source))) + ) + source = answer if answer.is_absolute() else Path.cwd() / answer + name = requested.name or str(typer.prompt("Template name", default=source.name)) + description = requested.description + if description is None or not description: + description = str( + typer.prompt("Template description", default=f"{name} template") + ) + use_case = requested.use_case + if use_case is None or not use_case: + use_case = str(typer.prompt("Template use case", default="")) + output = requested.output + if not has_output: + output = Path(_ask_library(libraries)) / TEMPLATES_DIR / name + get_console().print(f"Template will be created in: {output}") + return TemplateTarget(name, source, output, description, use_case) + + +def _ask_library(libraries: list[LibraryDict]) -> str: + """Ask which library stores the template and return its path.""" + get_console().print("\nChoose a library to store the template:") + for number, library in enumerate(libraries, start=1): + get_console().print(f"{number}. {library['name']} ({library['path']})") + while True: + selection: object = typer.prompt("Select library (number)", type=int, default=1) + if isinstance(selection, int) and 1 <= selection <= len(libraries): + return libraries[selection - 1]["path"] + get_console().print(f"Please enter a number between 1 and {len(libraries)}") + + +def _complete_without_prompts( + config: Config, requested: TemplateTarget, *, has_output: bool +) -> TemplateTarget | None: + """Default the name to the source directory and the output to the library. + + Returns None, after explaining why, when no library path is configured. + """ + name = requested.name or requested.source.name + output = requested.output + if not has_output: + library_path = config.get_symbol_library_path() + if library_path is None or not library_path: + get_console().print( + "No library configured. Use 'kilm init' to create one first." + ) + return None + output = Path(library_path) / TEMPLATES_DIR / name + return TemplateTarget( + name, requested.source, output, requested.description, requested.use_case + ) + + +def _print_request( + target: TemplateTarget, excludes: list[str], extends: str | None +) -> None: + console = get_console() + console.print(f"Creating template '{target.name}' from {target.source}") + console.print(f"Output directory: {target.output}") + if target.description is not None and target.description: + console.print(f"Description: {target.description}") + if target.use_case is not None and target.use_case: + console.print(f"Use case: {target.use_case}") + if excludes: + console.print("Additional exclusions:") + for pattern in excludes: + console.print(f" {pattern}") + if extends is not None and extends: + console.print(f"Extends: {extends}") + + +def _parse_variables(assignments: list[str]) -> dict[str, TemplateVariable]: + """Turn ``--variable name=value`` options into variables and show them.""" + variables = { + key: TemplateVariable(description=default_description(key), default=value) + for key, value in parse_assignments(assignments).items() + } + if variables: + get_console().print("\nTemplate variables:") + for key, info in variables.items(): + get_console().print(f" {key}: {info['default']} - {info['description']}") + return variables + + +def _ask_variables(source: Path) -> dict[str, TemplateVariable]: + """Offer the values found in the source as variables, then ask for more.""" + variables: dict[str, TemplateVariable] = {} + potential = find_potential_variables(source) + if potential: + get_console().print("\nFound potential template variables:") + for var_name, values in potential.items(): + get_console().print(f" {var_name}: {', '.join(values)}") + if typer.confirm(f" Use '{var_name}' as a template variable?", default=True): + description = typer.prompt( + " Description", default=default_description(var_name) + ) + variables[var_name] = TemplateVariable( + description=str(description), default=values[0] + ) + while typer.confirm( + "Would you like to define additional template variables?", default=False + ): + var_name = str(typer.prompt("Variable name")) + default = str(typer.prompt("Default value", default="")) + description = typer.prompt("Description", default=default_description(var_name)) + variables[var_name] = TemplateVariable( + description=str(description), default=default + ) + return variables + + +def _print_plan(plan: TemplatePlan) -> None: + """Show the files a dry run would include, template, and exclude.""" + console = get_console() + included = sorted(str(path) for path in plan.files) + console.print("\nFiles that will be included in the template:") + for file in included: + console.print(f" + {file}") + markdown = [file for file in included if file.lower().endswith(MARKDOWN_EXT)] + if markdown: + console.print("\nMarkdown files that will be converted to Jinja templates:") + for file in markdown: + console.print(f" * {file}") + if plan.main_files: + console.print("\nKiCad project files that will be templated:") + for kind, path in plan.main_files.items(): + console.print(f" * {path} → %{{{PROJECT_FILENAME_VARIABLE}}}{kind.value}") + console.print("\nFiles that will be excluded from the template:") + for exclusion in sorted(exclusion.path for exclusion in plan.excluded): + console.print(f" - {exclusion}") + + +def _print_next_steps(target: TemplateTarget) -> None: + console = get_console() + console.print(f"\nTemplate '{target.name}' created successfully at {target.output}") + console.print("\nNext steps:") + console.print( + f"1. Edit {target.output / TEMPLATE_METADATA} to customize template metadata" + ) + console.print( + f"2. Customize template files in {target.output / TEMPLATE_CONTENT_DIR}" + ) + console.print( + f"3. Edit post-creation hook in {target.output / HOOKS_DIR / POST_CREATE_HOOK}" + " if needed" + ) + console.print( + "4. Use your template with:" + f" kilm template create MyProject --template {target.name}" + ) + console.print("\nFilename Templating:") + console.print("For Windows compatibility, use %{variable} syntax in filenames:") + console.print(" - %{project_name}.kicad_pro") + console.print(" - %{project_name.lower}.kicad_sch") + console.print(" - %{project_name.replace(' ', '-')}.kicad_pcb") + console.print(" - %{project_name.upper.replace(' ', '_')}.md") + console.print( + "(Old {{variable}} syntax still works but may cause issues on Windows)" + ) diff --git a/kicad_lib_manager/commands/unpin/__init__.py b/kicad_lib_manager/commands/unpin/__init__.py index 90604f2..13f9b11 100644 --- a/kicad_lib_manager/commands/unpin/__init__.py +++ b/kicad_lib_manager/commands/unpin/__init__.py @@ -1,3 +1,5 @@ -from .command import unpin +"""The ``unpin`` command: remove libraries from KiCad's quick-access lists.""" + +from kicad_lib_manager.commands.unpin.command import unpin __all__ = ["unpin"] diff --git a/kicad_lib_manager/commands/unpin/command.py b/kicad_lib_manager/commands/unpin/command.py index 5c83038..b31dfda 100644 --- a/kicad_lib_manager/commands/unpin/command.py +++ b/kicad_lib_manager/commands/unpin/command.py @@ -1,195 +1,229 @@ -""" -Unpin command implementation for KiCad Library Manager (Typer version). -""" +"""The ``unpin`` command: remove libraries from KiCad's quick-access lists.""" -import json +from pathlib import Path +from typing import Annotated import typer -from rich.console import Console from rich.panel import Panel -from ...services.library_service import LibraryService -from ...utils.backup import create_backup - -console = Console() - - -def unpin( - symbols: list[str] | None = typer.Option( - None, - "--symbols", - "-s", - help="Symbol libraries to unpin (can be specified multiple times)", - ), - footprints: list[str] | None = typer.Option( - None, - "--footprints", - "-f", - help="Footprint libraries to unpin (can be specified multiple times)", - ), - all_libraries: bool = typer.Option(False, "--all", help="Unpin all libraries"), - dry_run: bool = typer.Option( - False, "--dry-run", help="Show what would be done without making changes" - ), - max_backups: int = typer.Option( - 5, "--max-backups", help="Maximum number of backups to keep" - ), - verbose: bool = typer.Option( - False, "--verbose", "-v", help="Show verbose output for debugging" - ), -) -> None: - """Unpin libraries in KiCad""" - # Handle None values from optional parameters - symbols = symbols or [] - footprints = footprints or [] - - # Enforce mutual exclusivity of --all with --symbols/--footprints - if all_libraries and (symbols or footprints): - console.print( - "[red]Error: '--all' cannot be used with '--symbols' or '--footprints'[/red]" - ) - raise typer.Exit(1) - - # Find KiCad configuration +from kicad_lib_manager.services.library_service import LibraryService +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import DEFAULT_MAX_BACKUPS, KICAD_COMMON_FILE +from kicad_lib_manager.utils.kicad_common import ( + PINNED_FOOTPRINTS_KEY, + PINNED_SYMBOLS_KEY, + as_list, + load_json_object, + pinned_names, + replace_json_object, + session_of, +) + +NO_SESSION = ( + "[yellow]No session information found in KiCad configuration, " + "nothing to unpin[/yellow]" +) + + +def _find_kicad_common(*, verbose: bool) -> Path: + """Return the path of kicad_common.json; exit when KiCad is not configured.""" + console = get_console() try: kicad_config = LibraryService.find_kicad_config() - if verbose: - console.print(f"Found KiCad configuration at: [blue]{kicad_config}[/blue]") except Exception as e: console.print(f"[red]Error finding KiCad configuration: {e}[/red]") raise typer.Exit(1) from e + if verbose: + console.print(f"Found KiCad configuration at: [blue]{kicad_config}[/blue]") + return kicad_config / KICAD_COMMON_FILE + + +def _all_pinned(kicad_common: Path, *, verbose: bool) -> tuple[list[str], list[str]]: + """Return every pinned symbol and footprint library. + + Exits with status 1 when the file cannot be read, and with status 0 when + nothing is pinned. + """ + console = get_console() + try: + session = session_of(load_json_object(kicad_common)) + except Exception as e: + console.print(f"[red]Error reading pinned libraries: {e}[/red]") + raise typer.Exit(1) from e + if session is None: + console.print(NO_SESSION) + raise typer.Exit + symbols = pinned_names(session, PINNED_SYMBOLS_KEY) + footprints = pinned_names(session, PINNED_FOOTPRINTS_KEY) + if verbose and symbols: + console.print(f"Found [cyan]{len(symbols)}[/cyan] pinned symbol libraries") + if verbose and footprints: + console.print( + f"Found [cyan]{len(footprints)}[/cyan] pinned footprint libraries" + ) + if not symbols and not footprints: + console.print("[yellow]No pinned libraries found, nothing to unpin[/yellow]") + raise typer.Exit + return symbols, footprints + + +def _remove_pins( + kicad_common: Path, + unpinned: dict[str, list[str]], + *, + dry_run: bool, + max_backups: int, +) -> bool | None: + """Remove the libraries in ``unpinned`` from the pinned lists they name. + + ``unpinned`` maps a pinned-list key to the libraries to remove from it. + Returns whether any pinned list changes, or None when the file has no + session. Unless ``dry_run`` is set, a changed file is backed up and then + replaced atomically. + """ + config = load_json_object(kicad_common) + session = session_of(config) + if session is None: + return None + changed = False + for key, libraries in unpinned.items(): + if key not in session or not libraries: + continue + current = as_list(session[key]) + kept = [library for library in current if library not in libraries] + if len(kept) != len(current): + changed = True + session[key] = kept + if changed and not dry_run: + config["session"] = session + replace_json_object(kicad_common, config, max_backups=max_backups) + return changed + + +def _report( + symbols: list[str], footprints: list[str], *, changed: bool, dry_run: bool +) -> None: + """Print the outcome of an unpin request.""" + console = get_console() + counts = f"{len(symbols)} symbol and {len(footprints)} footprint libraries" + if not changed: + console.print( + "[yellow]No changes needed, libraries already unpinned in KiCad[/yellow]" + ) + elif dry_run: + console.print(f"[yellow]Would unpin {counts} in KiCad[/yellow]") + else: + console.print( + Panel( + f"[green]Unpinned {counts} in KiCad[/green]\n\n" + "[blue]• Created backup of kicad_common.json[/blue]\n" + "[yellow]• Restart KiCad for changes to take effect[/yellow]", + title="Libraries Unpinned", + border_style="green", + ) + ) - # Get the kicad_common.json file - kicad_common = kicad_config / "kicad_common.json" + +def _print_names(kind: str, names: list[str]) -> None: + """List ``names`` under a heading for the ``kind`` of library.""" + if not names: + return + console = get_console() + console.print(f"\n[bold cyan]Unpinned {kind} Libraries:[/bold cyan]") + for name in sorted(names): + console.print(f" • [cyan]{name}[/cyan]") + + +def unpin( # noqa: PLR0913 -- each CLI option is a Typer parameter + *, + symbols: Annotated[ + list[str] | None, + typer.Option( + "--symbols", + "-s", + help="Symbol libraries to unpin (can be specified multiple times)", + ), + ] = None, + footprints: Annotated[ + list[str] | None, + typer.Option( + "--footprints", + "-f", + help="Footprint libraries to unpin (can be specified multiple times)", + ), + ] = None, + all_libraries: Annotated[ + bool, typer.Option("--all", help="Unpin all libraries") + ] = False, + dry_run: Annotated[ + bool, + typer.Option( + "--dry-run", help="Show what would be done without making changes" + ), + ] = False, + max_backups: Annotated[ + int, typer.Option("--max-backups", help="Maximum number of backups to keep") + ] = DEFAULT_MAX_BACKUPS, + verbose: Annotated[ + bool, + typer.Option("--verbose", "-v", help="Show verbose output for debugging"), + ] = False, +) -> None: + """Remove libraries from KiCad's pinned lists in kicad_common.json. + + Takes the named --symbols and --footprints, or every pinned library with + --all, and backs up the file before changing it. A missing file or + session leaves nothing to unpin. Exits 1 when no library is named, + --all is combined with names, or the file cannot be updated. + """ + console = get_console() + selected_symbols = symbols or [] + selected_footprints = footprints or [] + if all_libraries and (selected_symbols or selected_footprints): + console.print( + "[red]Error: '--all' cannot be used with '--symbols' or " + "'--footprints'[/red]" + ) + raise typer.Exit(1) + + kicad_common = _find_kicad_common(verbose=verbose) if not kicad_common.exists(): console.print( - "[yellow]KiCad common configuration file not found, nothing to unpin[/yellow]" + "[yellow]KiCad common configuration file not found, " + "nothing to unpin[/yellow]" ) return - # If --all is specified, unpin all libraries if all_libraries: - try: - with kicad_common.open() as f: - config = json.load(f) - - # Get all pinned libraries from kicad_common.json - if "session" in config: - symbols = config["session"].get("pinned_symbol_libs", []) - footprints = config["session"].get("pinned_fp_libs", []) - - if verbose: - if symbols: - console.print( - f"Found [cyan]{len(symbols)}[/cyan] pinned symbol libraries" - ) - if footprints: - console.print( - f"Found [cyan]{len(footprints)}[/cyan] pinned footprint libraries" - ) - else: - console.print( - "[yellow]No session information found in KiCad configuration, nothing to unpin[/yellow]" - ) - return - - if not symbols and not footprints: - console.print( - "[yellow]No pinned libraries found, nothing to unpin[/yellow]" - ) - return - except Exception as e: - console.print(f"[red]Error reading pinned libraries: {e}[/red]") - raise typer.Exit(1) from e - - # If no libraries are specified, print an error - if not symbols and not footprints and not all_libraries: + selected_symbols, selected_footprints = _all_pinned( + kicad_common, verbose=verbose + ) + elif not selected_symbols and not selected_footprints: console.print("[red]Error: No libraries specified to unpin[/red]") console.print( - "[yellow]Use --symbols, --footprints, or --all to specify libraries to unpin[/yellow]" + "[yellow]Use --symbols, --footprints, or --all to specify libraries " + "to unpin[/yellow]" ) raise typer.Exit(1) - # Ensure we have lists (Typer already handles this) - symbols = symbols or [] - footprints = footprints or [] - - # Unpin the libraries by removing them from the kicad_common.json file try: - with kicad_common.open() as f: - config = json.load(f) - - changes_needed = False - - # Ensure session section exists - if "session" not in config: - console.print( - "[yellow]No session information found in KiCad configuration, nothing to unpin[/yellow]" - ) - return - - # Handle symbol libraries - if "pinned_symbol_libs" in config["session"] and symbols: - current_symbols = config["session"]["pinned_symbol_libs"] - new_symbols = [lib for lib in current_symbols if lib not in symbols] - - if len(new_symbols) != len(current_symbols): - changes_needed = True - if not dry_run: - config["session"]["pinned_symbol_libs"] = new_symbols - - # Handle footprint libraries - if "pinned_fp_libs" in config["session"] and footprints: - current_footprints = config["session"]["pinned_fp_libs"] - new_footprints = [ - lib for lib in current_footprints if lib not in footprints - ] - - if len(new_footprints) != len(current_footprints): - changes_needed = True - if not dry_run: - config["session"]["pinned_fp_libs"] = new_footprints - - # Write changes if needed - if changes_needed and not dry_run: - # Create backup before making changes - create_backup(kicad_common, max_backups) - - with kicad_common.open("w") as f: - json.dump(config, f, indent=2) - - if changes_needed: - if dry_run: - console.print( - f"[yellow]Would unpin {len(symbols) if symbols else 0} symbol and {len(footprints) if footprints else 0} footprint libraries in KiCad[/yellow]" - ) - else: - success_msg = f"[green]Unpinned {len(symbols) if symbols else 0} symbol and {len(footprints) if footprints else 0} footprint libraries in KiCad[/green]" - console.print( - Panel( - f"{success_msg}\n\n" - f"[blue]• Created backup of kicad_common.json[/blue]\n" - f"[yellow]• Restart KiCad for changes to take effect[/yellow]", - title="✅ Libraries Unpinned", - border_style="green", - ) - ) - else: - console.print( - "[yellow]No changes needed, libraries already unpinned in KiCad[/yellow]" - ) - - if verbose: - if symbols: - console.print("\n[bold cyan]Unpinned Symbol Libraries:[/bold cyan]") - for symbol in sorted(symbols): - console.print(f" • [cyan]{symbol}[/cyan]") - - if footprints: - console.print("\n[bold cyan]Unpinned Footprint Libraries:[/bold cyan]") - for footprint in sorted(footprints): - console.print(f" • [cyan]{footprint}[/cyan]") - + changed = _remove_pins( + kicad_common, + { + PINNED_SYMBOLS_KEY: selected_symbols, + PINNED_FOOTPRINTS_KEY: selected_footprints, + }, + dry_run=dry_run, + max_backups=max_backups, + ) except Exception as e: console.print(f"[red]Error unpinning libraries: {e}[/red]") raise typer.Exit(1) from e + if changed is None: + console.print(NO_SESSION) + return + + _report(selected_symbols, selected_footprints, changed=changed, dry_run=dry_run) + if verbose: + _print_names("Symbol", selected_symbols) + _print_names("Footprint", selected_footprints) diff --git a/kicad_lib_manager/commands/update/__init__.py b/kicad_lib_manager/commands/update/__init__.py index 73ffd45..53ad7b4 100644 --- a/kicad_lib_manager/commands/update/__init__.py +++ b/kicad_lib_manager/commands/update/__init__.py @@ -1,13 +1,13 @@ -"""Update command module.""" +"""The `kilm update` command group.""" -from .check import check_update_command -from .command import update_app -from .info import info_command -from .perform import perform_update_command +from kicad_lib_manager.commands.update.check import check_update_command +from kicad_lib_manager.commands.update.command import update_app +from kicad_lib_manager.commands.update.info import info_command +from kicad_lib_manager.commands.update.perform import perform_update_command __all__ = [ - "update_app", "check_update_command", "info_command", "perform_update_command", + "update_app", ] diff --git a/kicad_lib_manager/commands/update/check.py b/kicad_lib_manager/commands/update/check.py index ff9f496..f38ff12 100644 --- a/kicad_lib_manager/commands/update/check.py +++ b/kicad_lib_manager/commands/update/check.py @@ -1,17 +1,16 @@ -"""Update check command implementation.""" +"""`kilm update check`: report whether a newer KiLM release exists.""" import importlib.metadata from typing import Annotated import typer -from rich.console import Console -from ...services.update_service import UpdateService - -console = Console() +from kicad_lib_manager.services.update_service import UpdateService +from kicad_lib_manager.utils.console import get_console def check_update_command( + *, force: Annotated[ bool, typer.Option(help="Force fresh check, bypass cache") ] = False, @@ -20,37 +19,29 @@ def check_update_command( typer.Option("--quiet", "-q", help="Only show output if update is available"), ] = False, ) -> None: - """Check for available updates without installing.""" - - version = importlib.metadata.version("kilm") - update_service = UpdateService(version) - - # Use force flag to bypass cache - update_info = update_service.check_for_updates(use_cache=not force) - - if update_info["has_update"]: - current = update_info["current_version"] - latest = update_info["latest_version"] - method = update_info["method"] - can_auto_update = update_info["supports_auto_update"] + """Check for available updates without installing. - console.print("[yellow]Update available![/yellow]") - console.print(f"Current version: [blue]{current}[/blue]") - console.print(f"Latest version: [green]{latest}[/green]") - console.print(f"Installation method: [cyan]{method}[/cyan]") + Exits with status 1 when an update is available, 0 otherwise. + """ + console = get_console() + update_service = UpdateService(importlib.metadata.version("kilm")) + status = update_service.check_for_updates(use_cache=not force) - if can_auto_update: - console.print("\nRun [bold cyan]kilm update perform[/bold cyan] to update") - else: - update_cmd = update_service.get_update_instructions() - console.print(f"\nManual update required: [cyan]{update_cmd}[/cyan]") - - # Exit with code 1 to indicate update available - raise typer.Exit(1) - else: + if not status["has_update"]: if not quiet: console.print( - f"[green]✓[/green] You are using the latest version ([bold cyan]v{update_info['current_version']}[/bold cyan])" + "[green]You are using the latest version[/green]" + f" ([bold cyan]v{status['current_version']}[/bold cyan])" ) - # Exit with code 0 for no update needed raise typer.Exit(0) + + console.print("[yellow]Update available![/yellow]") + console.print(f"Current version: [blue]{status['current_version']}[/blue]") + console.print(f"Latest version: [green]{status['latest_version']}[/green]") + console.print(f"Installation method: [cyan]{status['method']}[/cyan]") + if status["supports_auto_update"]: + console.print("\nRun [bold cyan]kilm update perform[/bold cyan] to update") + else: + instruction = update_service.update_instruction() + console.print(f"\nManual update required: [cyan]{instruction}[/cyan]") + raise typer.Exit(1) diff --git a/kicad_lib_manager/commands/update/command.py b/kicad_lib_manager/commands/update/command.py index dd2e141..37a94ce 100644 --- a/kicad_lib_manager/commands/update/command.py +++ b/kicad_lib_manager/commands/update/command.py @@ -1,20 +1,14 @@ -""" -Update command group for KiCad Library Manager. -Contains subcommands for checking, showing info, and performing updates. -""" +"""Registration of the `kilm update` subcommands.""" import typer -from .check import check_update_command -from .info import info_command -from .perform import perform_update_command +from kicad_lib_manager.commands.update.check import check_update_command +from kicad_lib_manager.commands.update.info import info_command +from kicad_lib_manager.commands.update.perform import perform_update_command -# Create the update command group update_app = typer.Typer( name="update", help="Update KiLM CLI to the latest version", no_args_is_help=True ) - -# Register subcommands update_app.command("check")(check_update_command) update_app.command("info")(info_command) update_app.command("perform")(perform_update_command) diff --git a/kicad_lib_manager/commands/update/docs.mdx b/kicad_lib_manager/commands/update/docs.mdx index 0488de3..a29abf8 100644 --- a/kicad_lib_manager/commands/update/docs.mdx +++ b/kicad_lib_manager/commands/update/docs.mdx @@ -21,6 +21,8 @@ kilm update perform [OPTIONS] Running `kilm update` without a subcommand prints the available subcommands. +The latest version found on PyPI is cached for a day in `~/.cache/kilm/version_check.json`; within that day `check`, `info`, `perform`, and the update notice in `kilm --help` reuse it unless a bypass option is given. + ## Subcommands ### check @@ -40,9 +42,9 @@ Shows the current version, whether a newer one exists, the detected installation Installs the update. If KiLM is already up to date, it exits without changes unless `--force` is given. -- `--target-version`, `-t` `TEXT`: Version to install. Currently only `--dry-run` output uses it; an actual update always installs the latest release. +- `--target-version`, `-t` `TEXT`: Install this version instead of the latest release, even if it is older than the installed one. Without `--force`, asking for the installed version changes nothing. A value that is not a version number is rejected with status `2`. - `--force`, `-f`: Reinstall even if KiLM is already up to date. -- `--dry-run`: Show what would be updated without changing anything. +- `--dry-run`: Show the command that would run without changing anything. Like a real update, it exits with status `1` when the installation method does not support automatic updates. - `--no-cache`: Bypass the cached version check and query PyPI. Every subcommand also accepts `--help`. @@ -54,11 +56,17 @@ Every subcommand also accepts `--help`. | **pipx** | Yes | `pipx upgrade kilm` | | **pip** | Yes | `pip install --upgrade kilm` | | **pip (venv)** | Yes | `pip install --upgrade kilm` | -| **uv** | Yes | `uv tool upgrade kilm` | +| **uv** | Yes | `uv tool install kilm@latest` | | **conda** | No | Not yet published | | **homebrew** | No | Not yet published | +| **nix** | No | `nix flake update`, then rebuild the configuration or profile that installs kilm | +| **unknown** | No | Update KiLM with the tool that installed it | + +KiLM reports **unknown** when nothing identifies the installer, for example a distribution package, rather than guessing pip. + +With `--target-version`, the automatic methods install that exact version: `pipx install --force kilm==VERSION`, `uv tool install kilm==VERSION`, or `pip install --upgrade kilm==VERSION`. -For methods without automatic updates, `kilm update perform` prints the manual command and exits with status `1`. +For methods without automatic updates, `kilm update perform` prints the manual instruction and exits with status `1`. When the installer itself fails, it prints the last lines of the installer's error output and the command to run by hand, and exits with status `1`. ## Examples diff --git a/kicad_lib_manager/commands/update/info.py b/kicad_lib_manager/commands/update/info.py index 07a129f..fd5ce1c 100644 --- a/kicad_lib_manager/commands/update/info.py +++ b/kicad_lib_manager/commands/update/info.py @@ -1,62 +1,52 @@ -"""Update info command implementation.""" +"""`kilm update info`: show how KiLM is installed and how it updates.""" import importlib.metadata from typing import Annotated import typer -from rich.console import Console from rich.panel import Panel -from ...services.update_service import UpdateService - -console = Console() +from kicad_lib_manager.services.update_service import UpdateService +from kicad_lib_manager.utils.console import get_console def info_command( + *, force_check: Annotated[ bool, typer.Option(help="Force fresh version check, bypass cache") ] = False, ) -> None: """Show detailed installation and update information.""" + update_service = UpdateService(importlib.metadata.version("kilm")) + status = update_service.check_for_updates(use_cache=not force_check) - version = importlib.metadata.version("kilm") - update_service = UpdateService(version) - - # Get installation and update information - update_info = update_service.check_for_updates(use_cache=not force_check) - method = update_service.get_installation_method() - can_auto_update = update_service.can_auto_update() - update_cmd = update_service.get_update_instructions() - - # Current version status - version_status = f"[green]{update_info['current_version']}[/green]" - if update_info["has_update"]: - version_status += ( - f" → [yellow]{update_info['latest_version']} available[/yellow]" - ) + version_status = f"[green]{status['current_version']}[/green]" + if status["has_update"]: + version_status += f" → [yellow]{status['latest_version']} available[/yellow]" else: version_status += " [dim](latest)[/dim]" - # Installation details - install_details = [ - f"Method: [cyan]{method}[/cyan]", - f"Auto-update: [{'green' if can_auto_update else 'red'}]{can_auto_update}[/]", - f"Update command: [cyan]{update_cmd}[/cyan]", - ] - - # Display information - console.print( + auto_update = status["supports_auto_update"] + auto_update_color = "green" if auto_update else "red" + instruction = update_service.update_instruction() + get_console().print( Panel( - f"""[bold]Version Information[/bold] -Current version: {version_status} - -[bold]Installation Details[/bold] -{chr(10).join(install_details)} - -[bold]Available Commands[/bold] -[cyan]kilm update check[/cyan] - Check for updates -[cyan]kilm update perform[/cyan] - Install updates -[cyan]kilm update info[/cyan] - Show this information""", + "\n".join( + [ + "[bold]Version Information[/bold]", + f"Current version: {version_status}", + "", + "[bold]Installation Details[/bold]", + f"Method: [cyan]{status['method']}[/cyan]", + f"Auto-update: [{auto_update_color}]{auto_update}[/]", + f"Update command: [cyan]{instruction}[/cyan]", + "", + "[bold]Available Commands[/bold]", + "[cyan]kilm update check[/cyan] - Check for updates", + "[cyan]kilm update perform[/cyan] - Install updates", + "[cyan]kilm update info[/cyan] - Show this information", + ] + ), title="[bold blue]KiLM Installation Info[/bold blue]", border_style="blue", ) diff --git a/kicad_lib_manager/commands/update/perform.py b/kicad_lib_manager/commands/update/perform.py index 6a47054..c175672 100644 --- a/kicad_lib_manager/commands/update/perform.py +++ b/kicad_lib_manager/commands/update/perform.py @@ -1,18 +1,28 @@ -"""Update perform command implementation.""" +"""`kilm update perform`: install a new KiLM release with its own installer.""" + +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"packaging.version"} import importlib.metadata from typing import Annotated import typer -from rich.console import Console +from packaging.version import InvalidVersion, Version from rich.panel import Panel -from ...services.update_service import UpdateService +from kicad_lib_manager.services.update_service import UpdateService +from kicad_lib_manager.utils.console import get_console -console = Console() +BREAKING_CHANGE_NOTICE = ( + "[bold yellow]BREAKING CHANGE NOTICE (KiLM 0.4.0)[/bold yellow]\n\n" + "The [bold]kilm update[/bold] command now updates KiLM itself.\n" + "To update library content, use [bold cyan]kilm sync[/bold cyan] instead.\n" + "This notice will be removed in a future version." +) def perform_update_command( + *, target_version: Annotated[ str | None, typer.Option( @@ -34,87 +44,116 @@ def perform_update_command( typer.Option("--no-cache", help="Skip cache and force fresh version check"), ] = False, ) -> None: - """Perform KiLM update installation. + """Install a newer KiLM release. - This command updates KiLM itself by downloading and installing the latest - version from PyPI. The update method depends on how KiLM was installed - (pip, pipx, uv tool, etc.). + Installs the latest release from PyPI, or --target-version, with the + installer that installed KiLM (pip, pipx, uv tool, etc.). Without --force + it stops when that version is already installed. Installations KiLM + cannot update get the command to run by hand and exit 1. - ⚠️ DEPRECATION NOTICE: - In KiLM 0.4.0, the 'update' command now updates KiLM itself. - To update library content, use 'kilm sync' instead. - This banner will be removed in a future version. + Since KiLM 0.4.0 'kilm update' updates KiLM itself; 'kilm sync' updates + library content. """ - - # Show deprecation notice - deprecation_notice = ( - "[bold yellow]⚠️ BREAKING CHANGE NOTICE (KiLM 0.4.0)[/bold yellow]\n\n" - "The [bold]kilm update[/bold] command now updates KiLM itself.\n" - "To update library content, use [bold cyan]kilm sync[/bold cyan] instead.\n" - "This notice will be removed in a future version." - ) - console.print(Panel(deprecation_notice, expand=False, border_style="yellow")) - - version = importlib.metadata.version("kilm") - update_service = UpdateService(version) + console = get_console() + console.print(Panel(BREAKING_CHANGE_NOTICE, expand=False, border_style="yellow")) + if target_version is not None: + _validate_version(target_version) + update_service = UpdateService(importlib.metadata.version("kilm")) if dry_run: console.print("[yellow]Dry run mode - showing what would be done[/yellow]") console.print() + # A dry run fails the same way as the real update would. + if not update_service.can_auto_update: + _exit_with_manual_instruction(update_service) + if dry_run: + _show_update_plan(update_service, target_version) + raise typer.Exit(0) + if not force: + _announce_update_or_exit(update_service, target_version, use_cache=not no_cache) - # Check if we can auto-update (skip during dry run) - if not dry_run and not update_service.can_auto_update(): - console.print( - "[yellow]⚠[/yellow] Automatic update not supported for your installation method." - ) - method = update_service.get_installation_method() - update_cmd = update_service.get_update_instructions() - - console.print(f"Installation method: [bold]{method}[/bold]") - console.print(f"Please run manually: [bold cyan]{update_cmd}[/bold cyan]") + result = update_service.perform_update(target_version) + if not result.success: + console.print(f"\n[red]Update failed:[/red] {result.message}") raise typer.Exit(1) + console.print("\n[green]Update completed successfully![/green]") + console.print( + "You may need to restart your shell or run [cyan]hash -r[/cyan]" + " to use the new version" + ) - # Perform update check first (unless forced) - if not force and not dry_run: - update_info = update_service.check_for_updates(use_cache=not no_cache) - if not update_info["has_update"]: - console.print( - f"[green]✓[/green] Already using the latest version ([bold cyan]v{update_info['current_version']}[/bold cyan])" - ) - console.print("\nUse [cyan]--force[/cyan] to reinstall the current version") - raise typer.Exit(0) - current = update_info["current_version"] - latest = update_info["latest_version"] - target = target_version or latest +def _validate_version(target_version: str) -> None: + """Reject a --target-version that is not a version number (exit status 2).""" + try: + Version(target_version) + except InvalidVersion: + message = f"'{target_version}' is not a version number" + raise typer.BadParameter(message, param_hint="--target-version") from None - if not dry_run: - console.print( - f"Updating from [blue]{current}[/blue] to [green]{target}[/green]" - ) - console.print() - # Perform the update - success, message = update_service.perform_update( - target_version=target_version, force=force, dry_run=dry_run +def _exit_with_manual_instruction(update_service: UpdateService) -> None: + """Explain how to update an installation KiLM cannot update, then exit 1.""" + console = get_console() + console.print( + "[yellow]Warning:[/yellow] Automatic update not supported" + " for your installation method." ) + console.print(f"Installation method: [bold]{update_service.method}[/bold]") + instruction = update_service.update_instruction() + console.print(f"Update manually: [bold cyan]{instruction}[/bold cyan]") + raise typer.Exit(1) - if dry_run: - if success: + +def _announce_update_or_exit( + update_service: UpdateService, target_version: str | None, *, use_cache: bool +) -> None: + """Print what the update installs, or exit 0 when nothing would change.""" + console = get_console() + current = update_service.current_version + if target_version is not None: + if Version(target_version) == Version(current): console.print( - "\n[green]✓[/green] Dry run completed - automatic update is supported" + f"[green]Already using version[/green] [bold cyan]{current}[/bold cyan]" ) - else: - console.print("\n[red]✗[/red] Automatic update not available") - console.print("Manual update will be required") - raise typer.Exit(0) + raise typer.Exit(0) + target = target_version + else: + status = update_service.check_for_updates(use_cache=use_cache) + if not status["has_update"]: + console.print( + "[green]Already using the latest version[/green]" + f" ([bold cyan]v{current}[/bold cyan])" + ) + console.print("\nUse [cyan]--force[/cyan] to reinstall the current version") + raise typer.Exit(0) + target = status["latest_version"] + console.print(f"Updating from [blue]{current}[/blue] to [green]{target}[/green]") + console.print() - if success: - console.print("\n[green]✓[/green] Update completed successfully!") - console.print( - "You may need to restart your shell or run [cyan]hash -r[/cyan] to use the new version" + +def _show_update_plan( + update_service: UpdateService, target_version: str | None +) -> None: + """Print the installer command a real update would run.""" + package_spec = "kilm" if target_version is None else f"kilm=={target_version}" + command = update_service.update_instruction(target_version) + get_console().print( + Panel( + "\n".join( + [ + "[bold]Update Plan (Dry Run)[/bold]", + "", + f"Installation method: [cyan]{update_service.method}[/cyan]", + f"Target package: [green]{package_spec}[/green]", + "Auto-update supported: [green]True[/green]", + "", + "Command that would be executed:", + f"[bold cyan]{command}[/bold cyan]", + ] + ), + title="[bold yellow]Dry Run[/bold yellow]", + border_style="yellow", ) - else: - console.print(f"\n[red]✗[/red] Update failed: {message}") - console.print("Please try updating manually or check the error messages above") - raise typer.Exit(1) + ) + get_console().print("\nDry run completed - automatic update is supported") diff --git a/kicad_lib_manager/interfaces/__init__.py b/kicad_lib_manager/interfaces/__init__.py deleted file mode 100644 index 9f5a2aa..0000000 --- a/kicad_lib_manager/interfaces/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Service interfaces for KiCad Library Manager.""" diff --git a/kicad_lib_manager/interfaces/config_service.py b/kicad_lib_manager/interfaces/config_service.py deleted file mode 100644 index 94ed315..0000000 --- a/kicad_lib_manager/interfaces/config_service.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Configuration service protocol interface for KiCad Library Manager. -""" - -from abc import abstractmethod -from pathlib import Path -from typing import Protocol - - -class ConfigServiceProtocol(Protocol): - """Protocol for configuration management services.""" - - @abstractmethod - def get_config_file_path(self) -> Path: - """Get the path to the KiLM configuration file.""" - - @abstractmethod - def load_config(self) -> dict: - """Load the KiLM configuration.""" - - @abstractmethod - def save_config(self, config: dict) -> None: - """Save the KiLM configuration.""" - - @abstractmethod - def add_library(self, name: str, path: str, library_type: str) -> None: - """Add a library to the configuration.""" - - @abstractmethod - def remove_library(self, name: str) -> None: - """Remove a library from the configuration.""" - - @abstractmethod - def get_libraries(self) -> list[dict]: - """Get all configured libraries.""" - - @abstractmethod - def get_library_by_name(self, name: str) -> dict | None: - """Get a specific library by name.""" - - @abstractmethod - def get_current_library(self) -> str | None: - """Get the current active library path.""" - - @abstractmethod - def set_current_library(self, path: str) -> None: - """Set the current active library.""" - - @abstractmethod - def get_max_backups(self) -> int: - """Get the maximum number of backups to keep.""" - - @abstractmethod - def set_max_backups(self, count: int) -> None: - """Set the maximum number of backups to keep.""" diff --git a/kicad_lib_manager/interfaces/kicad_service.py b/kicad_lib_manager/interfaces/kicad_service.py deleted file mode 100644 index 179dcd3..0000000 --- a/kicad_lib_manager/interfaces/kicad_service.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -KiCad service protocol interface for KiCad Library Manager. -""" - -from abc import abstractmethod -from pathlib import Path -from typing import Protocol - - -class KiCadServiceProtocol(Protocol): - """Protocol for KiCad configuration and management services.""" - - @abstractmethod - def find_kicad_config_dir(self) -> Path: - """Find the KiCad configuration directory.""" - - @abstractmethod - def get_environment_variables(self, config_dir: Path) -> dict[str, str]: - """Get KiCad environment variables from configuration.""" - - @abstractmethod - def set_environment_variables( - self, - config_dir: Path, - env_vars: dict[str, str], - backup: bool = True, - max_backups: int = 5, - ) -> bool: - """Set KiCad environment variables.""" - - @abstractmethod - def get_configured_libraries( - self, config_dir: Path - ) -> tuple[list[dict], list[dict]]: - """ - Get configured symbol and footprint libraries. - - Returns: - Tuple of (symbol_libraries, footprint_libraries) - """ - - @abstractmethod - def add_libraries_to_kicad( - self, - config_dir: Path, - symbol_libs: list[dict] | None = None, - footprint_libs: list[dict] | None = None, - backup: bool = True, - max_backups: int = 5, - ) -> bool: - """Add libraries to KiCad library tables.""" - - @abstractmethod - def get_pinned_libraries(self, config_dir: Path) -> tuple[list[str], list[str]]: - """ - Get pinned libraries from KiCad configuration. - - Returns: - Tuple of (symbol_libraries, footprint_libraries) - """ diff --git a/kicad_lib_manager/interfaces/library_service.py b/kicad_lib_manager/interfaces/library_service.py deleted file mode 100644 index 06b4cc1..0000000 --- a/kicad_lib_manager/interfaces/library_service.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Library service protocol interface for KiCad Library Manager. -""" - -from abc import abstractmethod -from pathlib import Path -from typing import Protocol - - -class LibraryServiceProtocol(Protocol): - """Protocol for library management services.""" - - @abstractmethod - def list_libraries(self, directory: Path) -> tuple[list[str], list[str]]: - """ - List symbol and footprint libraries in a directory. - - Returns: - Tuple of (symbol_libraries, footprint_libraries) - """ - ... - - @abstractmethod - def initialize_library( - self, - directory: Path, - name: str | None = None, - description: str | None = None, - env_var: str | None = None, - force: bool = False, - no_env_var: bool = False, - ) -> dict[str, str | bool | dict[str, bool]]: - """Initialize a library in the given directory.""" - ... - - @abstractmethod - def get_library_metadata( - self, directory: Path - ) -> dict[str, str | bool | dict[str, bool]] | None: - """Get metadata for a library directory.""" - ... - - @abstractmethod - def pin_libraries( - self, - symbol_libs: list[str], - footprint_libs: list[str], - kicad_config_dir: Path, - dry_run: bool = False, - max_backups: int = 5, - ) -> bool: - """ - Pin libraries in KiCad for quick access. - - Returns: - True if changes were made, False otherwise - """ - ... - - @abstractmethod - def unpin_libraries( - self, - symbol_libs: list[str], - footprint_libs: list[str], - kicad_config_dir: Path, - dry_run: bool = False, - max_backups: int = 5, - ) -> bool: - """ - Unpin libraries in KiCad. - - Returns: - True if changes were made, False otherwise - """ - ... diff --git a/kicad_lib_manager/main.py b/kicad_lib_manager/main.py index 574faf4..f9798c5 100644 --- a/kicad_lib_manager/main.py +++ b/kicad_lib_manager/main.py @@ -1,91 +1,89 @@ -#!/usr/bin/env python3 -""" -Main Typer CLI entry point for KiCad Library Manager -""" +"""The ``kilm`` command line: the Typer app and its command registry.""" import importlib.metadata +import logging from typing import Annotated +import click import typer from rich.align import Align -from rich.console import Console from rich.text import Text -from rich.traceback import install from typer.core import TyperGroup - -from .commands.add_3d import add_3d -from .commands.add_hook import add_hook -from .commands.config import config_app -from .commands.import_zip import import_zip -from .commands.init import init -from .commands.list_libraries import list_cmd -from .commands.pin import pin -from .commands.setup import setup -from .commands.status import status -from .commands.sync import sync -from .commands.template import template_app -from .commands.unpin import unpin -from .commands.update import update_app -from .services.config_service import ConfigService -from .services.update_service import UpdateService -from .utils.banner import show_banner +from typing_extensions import override + +from kicad_lib_manager.commands.add_3d import add_3d +from kicad_lib_manager.commands.add_hook import add_hook +from kicad_lib_manager.commands.config import config_app +from kicad_lib_manager.commands.import_zip import import_zip +from kicad_lib_manager.commands.init import init +from kicad_lib_manager.commands.list_libraries import list_cmd +from kicad_lib_manager.commands.pin import pin +from kicad_lib_manager.commands.setup import setup +from kicad_lib_manager.commands.status import status +from kicad_lib_manager.commands.sync import sync +from kicad_lib_manager.commands.template import template_app +from kicad_lib_manager.commands.unpin import unpin +from kicad_lib_manager.commands.update import update_app +from kicad_lib_manager.services.config_service import Config +from kicad_lib_manager.services.update_service import UpdateService +from kicad_lib_manager.utils.banner import show_banner +from kicad_lib_manager.utils.console import get_console TAGLINE = "Professional KiCad library management" -# Install rich traceback handler for better error display -install(show_locals=True) +logger = logging.getLogger(__name__) + -# Initialize Rich console -console = Console() +def _show_title() -> None: + """Print the centered banner and tagline.""" + console = get_console() + show_banner(console, justify="center") + console.print() + console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) + console.print() def _check_for_updates_if_needed() -> None: - """Check for updates if configured to do so, non-intrusively.""" - try: - config_service = ConfigService() - config = config_service._config + """Show a notice when a newer KiLM is available, unless disabled. - # Skip if update checking is disabled - if not config.get("update_check", True): + Both branches answer from the day-long PyPI cache and refresh it from + PyPI once it has expired; a check the configured frequency calls for is + recorded only when it finds an update. Any failure is logged + at debug level and ignored, because an update check must never break the + command the user ran. + """ + try: + config = Config() + enabled = config.get("update_check", True) + if enabled is None or not enabled: return - - version = importlib.metadata.version("kilm") - update_service = UpdateService(version) - - # Always show notification if update is available, regardless of frequency - # Only respect frequency for the actual PyPI API calls + update_service = UpdateService(importlib.metadata.version("kilm")) if config.should_check_updates(): - # Time to make a fresh API call if update_service.show_update_notification(quiet=True): config.mark_update_check_performed() - else: - # Use cached data to show notification if update available - update_info = update_service.check_for_updates(use_cache=True) - if update_info["has_update"]: - update_service.show_update_notification(quiet=True) - + elif update_service.check_for_updates(use_cache=True)["has_update"]: + update_service.show_update_notification(quiet=True) except Exception: - # Silently fail - don't break CLI functionality - pass + logger.debug("Update check failed", exc_info=True) class BannerGroup(TyperGroup): - """Custom group that shows banner before help.""" - - def format_help(self, ctx, formatter): - # Show banner before help - show_banner(console, justify="center") - console.print() - console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) - console.print() + """Root command group that prints the banner above its help.""" + + @override + def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + """Print the banner and any update notice, then the regular help.""" + _show_title() _check_for_updates_if_needed() super().format_help(ctx, formatter) -# Create main Typer app app = typer.Typer( name="kilm", - help="KiCad Library Manager - Manage KiCad libraries across projects and workstations", + help=( + "KiCad Library Manager - Manage KiCad libraries across projects and " + "workstations" + ), context_settings={"help_option_names": ["-h", "--help"]}, rich_markup_mode="rich", pretty_exceptions_enable=True, @@ -95,19 +93,22 @@ def format_help(self, ctx, formatter): ) -def version_callback(value: bool) -> None: - """Print version information and exit.""" - if value: - show_banner(console, justify="left") - console.print() - - version = importlib.metadata.version("kilm") - console.print(f"KiCad Library Manager (KiLM) version [cyan]{version}[/cyan]") - raise typer.Exit() +def version_callback(*, value: bool) -> None: + """Print the banner and installed version, then exit, when ``value`` is set.""" + if not value: + return + console = get_console() + show_banner(console, justify="left") + console.print() + version = importlib.metadata.version("kilm") + console.print(f"KiCad Library Manager (KiLM) version [cyan]{version}[/cyan]") + raise typer.Exit @app.callback() def main( + ctx: typer.Context, + *, _version: Annotated[ bool | None, typer.Option( @@ -119,32 +120,18 @@ def main( ), ] = None, ) -> None: - """ - [bold blue]KiCad Library Manager[/bold blue] - Professional KiCad library management - - This tool helps you configure and manage KiCad libraries across your projects - and workstations with a modern, type-safe CLI interface. + """Show the banner when no command is given; otherwise check for updates. - [bold]Common Commands:[/bold] - • [cyan]kilm status[/cyan] - Show current configuration - • [cyan]kilm setup[/cyan] - Configure KiCad to use libraries - • [cyan]kilm list[/cyan] - List available libraries - • [cyan]kilm sync[/cyan] - Update library content + The root help text comes from the ``help`` argument of ``app``, not from + this docstring. """ - # Show banner when no arguments are provided, centered - import sys - - if not sys.argv[1:]: - show_banner(console) - console.print() - console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) - console.print() - console.print( - "[dim]Use 'kilm --help' to see all commands.[/dim]", justify="center" - ) - else: - # Check for updates if configured to do so only if not banner + if ctx.invoked_subcommand is not None: _check_for_updates_if_needed() + return + _show_title() + get_console().print( + "[dim]Use 'kilm --help' to see all commands.[/dim]", justify="center" + ) # Leaf commands are registered as plain commands: a Typer sub-app with only a @@ -160,7 +147,9 @@ def main( app.command("add-hook", help="Add project hooks")(add_hook) app.command( "import", - help="Import SamacSys/Mouser/UltraLibrarian/SnapMagic KiCad ZIP(s) into the library", + help=( + "Import SamacSys/Mouser/UltraLibrarian/SnapMagic KiCad ZIP(s) into the library" + ), )(import_zip) # Groups keep their subcommands. app.add_typer(config_app, name="config", help="Manage configuration settings") diff --git a/kicad_lib_manager/services/__init__.py b/kicad_lib_manager/services/__init__.py index f8c2b40..c681aa2 100644 --- a/kicad_lib_manager/services/__init__.py +++ b/kicad_lib_manager/services/__init__.py @@ -1 +1 @@ -"""Service implementations for KiCad Library Manager.""" +"""Configuration, KiCad, library, and self-update services behind the commands.""" diff --git a/kicad_lib_manager/services/config_service.py b/kicad_lib_manager/services/config_service.py index 02586b3..67d8032 100644 --- a/kicad_lib_manager/services/config_service.py +++ b/kicad_lib_manager/services/config_service.py @@ -1,24 +1,25 @@ -""" -Configuration management for KiCad Library Manager. -""" +"""The KiLM configuration file: registered libraries and update-check settings.""" + +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"yaml"} import time from pathlib import Path -from typing import TypedDict, cast +from typing import Final, TypedDict, cast import click import yaml -from ..utils.constants import ( - CONFIG_DIR_NAME, - CONFIG_FILE_NAME, - DEFAULT_LIBRARIES, - DEFAULT_MAX_BACKUPS, -) +from kicad_lib_manager.services.user_paths import user_cache_dir, user_config_file +from kicad_lib_manager.utils.constants import DEFAULT_MAX_BACKUPS class LibraryDict(TypedDict): - """Type definition for library configuration.""" + """A library registered in the configuration. + + `type` is "github" for symbol and footprint libraries and "cloud" for 3D + model libraries. + """ name: str path: str @@ -26,496 +27,228 @@ class LibraryDict(TypedDict): ConfigValue = str | int | list[LibraryDict] +"""A configuration value; booleans are stored as the int subtype bool.""" -DEFAULT_CONFIG: dict[str, ConfigValue] = { - "max_backups": DEFAULT_MAX_BACKUPS, - "libraries": DEFAULT_LIBRARIES, - "update_check": True, - "update_check_frequency": "daily", # daily, weekly, never - "auto_update": False, # Never auto-update without permission +UPDATE_CHECK_INTERVALS: Final[dict[str, float]] = { + "daily": 24 * 60 * 60, + "weekly": 7 * 24 * 60 * 60, } +"""Seconds between update checks for each `update_check_frequency`. + +"never" disables checks; any other value triggers one only while no +previous check is recorded. +""" + +LAST_UPDATE_CHECK_FILE: Final = "last_update_check" +"""Cache file holding the time of the last update check.""" + + +def default_config() -> dict[str, ConfigValue]: + """Return a fresh configuration with every setting at its default.""" + return { + "max_backups": DEFAULT_MAX_BACKUPS, + "libraries": [], + "update_check": True, + "update_check_frequency": "daily", + "auto_update": False, + } class Config: - """ - Configuration manager for KiCad Library Manager + """The user's KiLM configuration, loaded at construction. + + Mutating methods save the file immediately. A missing file means the + defaults; an unreadable one is reported on stderr and also yields the + defaults. Library entries without a name, path, and type are dropped + with a warning when the file is loaded. """ - def __init__(self): - """Initialize configuration with default values""" - self._config = DEFAULT_CONFIG.copy() + def __init__( + self, *, config_file: Path | None = None, cache_dir: Path | None = None + ) -> None: + """Load the configuration. + + Args: + config_file: The YAML file; defaults to the user's KiLM config. + cache_dir: Where update-check times are kept; defaults to the + user's KiLM cache. + """ + self.config_file = config_file or user_config_file() + self._cache_dir = cache_dir or user_cache_dir() + self._config = default_config() self._load_config() def _load_config(self) -> None: - """Load configuration from file if it exists""" - config_file = self._get_config_file() - if config_file.exists(): - try: - with config_file.open() as f: - loaded_config = yaml.safe_load(f) - if loaded_config and isinstance(loaded_config, dict): - self._config.update(loaded_config) - # Ensure libraries field is always a list of dicts - self._normalize_libraries_field() - except Exception as e: - # Use click.echo for warnings/errors - click.echo(f"Error loading config file: {e}", err=True) - - def _get_config_file(self) -> Path: - """Get the configuration file path""" - config_dir = Path.home() / ".config" / CONFIG_DIR_NAME - config_dir.mkdir(parents=True, exist_ok=True) - return config_dir / CONFIG_FILE_NAME + """Merge the file's settings over the defaults, if the file exists.""" + try: + with self.config_file.open(encoding="utf-8") as f: + loaded = yaml.safe_load(f) + except FileNotFoundError: + return + except (OSError, UnicodeDecodeError, yaml.YAMLError) as e: + click.echo(f"Error loading config file: {e}", err=True) + return + if isinstance(loaded, dict): + self._config.update(loaded) + self._config["libraries"] = _normalize_libraries( + self._config.get("libraries") + ) def get(self, key: str, default: ConfigValue | None = None) -> ConfigValue | None: - """Get a configuration value""" + """Return the setting `key`, or `default` when it is not set.""" return self._config.get(key, default) - def set(self, key: str, value: ConfigValue) -> None: - """Set a configuration value""" - self._config[key] = value - def save(self) -> None: - """Save configuration to file""" - config_file = self._get_config_file() - with config_file.open("w") as f: + """Write the configuration file, creating its directory if needed.""" + self.config_file.parent.mkdir(parents=True, exist_ok=True) + with self.config_file.open("w", encoding="utf-8") as f: yaml.dump(self._config, f, default_flow_style=False) + def _libraries(self) -> list[LibraryDict]: + """Return the stored library list itself; callers may mutate it.""" + libraries = self._config.get("libraries") + if isinstance(libraries, list): + return libraries + libraries = [] + self._config["libraries"] = libraries + return libraries + def add_library(self, name: str, path: str, library_type: str = "github") -> None: - """ - Add a library to the configuration + """Register a library, or update the path of the one with this name and type. Args: - name: Library name - path: Path to the library - library_type: Type of library ("github" for symbols/footprints, "cloud" for 3D models) - """ - libraries = self._get_normalized_libraries() - - # Check if library already exists - for lib in libraries: - if lib["name"] == name and lib["type"] == library_type: - lib["path"] = str(path) - self.save() - return - - # Add new library - new_library = LibraryDict(name=name, path=str(path), type=library_type) - libraries.append(new_library) - - self._config["libraries"] = libraries + name: Library name. + path: Path to the library. + library_type: "github" for symbols and footprints, "cloud" for + 3D models. + """ + libraries = self._libraries() + for library in libraries: + if library["name"] == name and library["type"] == library_type: + library["path"] = str(path) + break + else: + libraries.append(LibraryDict(name=name, path=str(path), type=library_type)) self.save() def remove_library(self, name: str, library_type: str | None = None) -> bool: - """ - Remove a library from the configuration + """Unregister the libraries called `name`, of one type or of every type. - Args: - name: Library name - library_type: Type of library ("github" or "cloud"). If None, remove all types. + The default library is cleared once no remaining library has its path. Returns: - True if library was removed, False otherwise - """ - libraries = self._get_normalized_libraries() - original_count = len(libraries) - - if library_type: - filtered_libraries = [ - lib - for lib in libraries - if not (lib["name"] == name and lib["type"] == library_type) - ] - else: - filtered_libraries = [lib for lib in libraries if lib["name"] != name] - - self._config["libraries"] = filtered_libraries - removed = len(filtered_libraries) < original_count - if removed: - self.save() - - return removed + Whether any library was removed. + """ + libraries = self._libraries() + remaining = [ + library + for library in libraries + if library["name"] != name + or (library_type is not None and library["type"] != library_type) + ] + if len(remaining) == len(libraries): + return False + self._config["libraries"] = remaining + if self.get_current_library() not in {library["path"] for library in remaining}: + self._config.pop("current_library", None) + self.save() + return True def get_libraries(self, library_type: str | None = None) -> list[LibraryDict]: - """ - Get libraries from configuration - - Args: - library_type: Type of library ("github" or "cloud"). If None, get all types. - - Returns: - List of libraries - """ - libraries = self._get_normalized_libraries() - - if library_type: - return [lib for lib in libraries if lib["type"] == library_type] - else: - return libraries - - def get_library_path(self, name: str, library_type: str = "github") -> str | None: - """ - Get the path for a specific library - - Args: - name: Library name - library_type: Type of library ("github" or "cloud") - - Returns: - Path to the library or None if not found - """ - libraries = self._get_normalized_libraries() - - for lib in libraries: - if lib["name"] == name and lib["type"] == library_type: - return lib["path"] - - return None - - def get_current_library_paths(self) -> tuple[str | None, str | None]: - """ - Get paths for the current active libraries - - Returns: - Tuple of (github_library_path, cloud_library_path), either may be None - """ - # Get all libraries by type - github_libraries = self.get_libraries("github") - cloud_libraries = self.get_libraries("cloud") - - # Get the current explicitly set library path - current_lib_path = cast("str | None", self._config.get("current_library")) - - # For GitHub libraries: first check if current library is a GitHub library, - # otherwise use the first available GitHub library - github_lib_path = None - if current_lib_path: - # Check if current library is a GitHub library - for lib in github_libraries: - if lib["path"] == current_lib_path: - github_lib_path = current_lib_path - break - - # If no GitHub library was found or it's not the current lib, - # use the first available GitHub library - if not github_lib_path and github_libraries: - github_lib_path = github_libraries[0]["path"] - - # For cloud libraries: first check if current library is a cloud library, - # otherwise use the first available cloud library - cloud_lib_path = None - if current_lib_path: - # Check if current library is a cloud library - for lib in cloud_libraries: - if lib["path"] == current_lib_path: - cloud_lib_path = current_lib_path - break - - # If no cloud library was found or it's not the current lib, - # use the first available cloud library - if not cloud_lib_path and cloud_libraries: - cloud_lib_path = cloud_libraries[0]["path"] - - return github_lib_path, cloud_lib_path + """Return the registered libraries, optionally only those of one type.""" + return [ + library + for library in self._libraries() + if library_type is None or library["type"] == library_type + ] def set_current_library(self, path: str) -> None: - """ - Set the current active library path - - Args: - path: Path to the library - """ + """Make the library at `path` the default and save.""" self._config["current_library"] = str(path) self.save() def get_current_library(self) -> str | None: - """ - Get the current active library path - - Returns: - Path to the current library or None if not set - """ + """Return the path of the default library, if one is set.""" value = self._config.get("current_library") return value if isinstance(value, str) else None def get_symbol_library_path(self) -> str | None: - """ - Get the path for the current symbol library - - Returns: - Path to the symbol library or None if not found - """ - github_path, _ = self.get_current_library_paths() - return github_path + """Return the symbol library to use: the default or else the first one.""" + return self._current_or_first("github") def get_3d_library_path(self) -> str | None: - """ - Get the path for the current 3D models library - - Returns: - Path to the 3D models library or None if not found - """ - _, cloud_path = self.get_current_library_paths() - return cloud_path - - def _normalize_libraries_field(self) -> None: - """Ensure libraries field is always a properly typed list of dictionaries.""" - libraries = self._config.get("libraries", []) - - # 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, - ) - self._config["libraries"] = [] - return - - # Validate and clean up each library entry - normalized_libraries: list[LibraryDict] = [] - for lib in libraries: - 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) - - self._config["libraries"] = normalized_libraries - - def _get_normalized_libraries(self) -> list[LibraryDict]: - """ - Get libraries ensuring they are properly normalized. + """Return the 3D model library to use: the default or else the first one.""" + return self._current_or_first("cloud") - Returns: - List of properly typed library dictionaries - """ - libraries_raw = self._config.get("libraries", []) - - # 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, - ) - self._config["libraries"] = [] - return [] - - # Validate and normalize each library entry - normalized_libraries: list[LibraryDict] = [] - needs_save = False - - for lib in libraries_raw: - 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 - - # Update config if we had to clean up invalid entries - if needs_save: - self._config["libraries"] = normalized_libraries - self.save() - - return normalized_libraries + def _current_or_first(self, library_type: str) -> str | None: + """Return the default library if it has this type, else the first such.""" + paths = [library["path"] for library in self.get_libraries(library_type)] + current = self.get_current_library() + if current in paths: + return current + return paths[0] if paths else None def should_check_updates(self) -> bool: - """ - Determine if update check should run based on configuration. + """Return whether the update check frequency calls for a check now. - Returns: - True if update check should be performed + Without a readable record of the last check, a check is due unless + checks are disabled or the frequency is "never". """ - if not self.get("update_check", True): + if not bool(self._config.get("update_check", True)): return False - - frequency = self.get("update_check_frequency", "daily") + frequency = self._config.get("update_check_frequency", "daily") if frequency == "never": return False - - # Check if we've checked recently based on frequency - cache_dir = Path.home() / ".cache" / "kilm" - last_check_file = cache_dir / "last_update_check" - - if not last_check_file.exists(): - return True - try: - last_check = float(last_check_file.read_text().strip()) - now = time.time() - - if frequency == "daily": - return now - last_check > 86400 # 24 hours - elif frequency == "weekly": - return now - last_check > 604800 # 7 days + last_check = float(self._last_check_file().read_text().strip()) except (ValueError, OSError): return True - - return False + interval = ( + UPDATE_CHECK_INTERVALS.get(frequency) + if isinstance(frequency, str) + else None + ) + return interval is not None and time.time() - last_check > interval def mark_update_check_performed(self) -> None: - """Mark that an update check was performed.""" - - cache_dir = Path.home() / ".cache" / "kilm" - cache_dir.mkdir(parents=True, exist_ok=True) - last_check_file = cache_dir / "last_update_check" - last_check_file.write_text(str(time.time())) - - def get_update_preferences(self) -> dict[str, bool | str]: - """ - Get update-related preferences. + """Record that an update check ran now.""" + self._cache_dir.mkdir(parents=True, exist_ok=True) + self._last_check_file().write_text(str(time.time())) - Returns: - Dictionary containing update preferences - """ - return { - "update_check": bool(self.get("update_check", True)), - "update_check_frequency": str(self.get("update_check_frequency", "daily")), - "auto_update": bool(self.get("auto_update", False)), - } - - def set_update_preference(self, key: str, value: bool | str) -> None: - """ - Set an update preference with strict type validation and coercion. - - Args: - key: Preference key ('update_check', 'update_check_frequency', 'auto_update') - value: Preference value (will be coerced to appropriate type) - """ - valid_keys = {"update_check", "update_check_frequency", "auto_update"} - if key not in valid_keys: - raise ValueError(f"Invalid update preference key: {key}") - - # Handle boolean keys with type coercion - if key in {"update_check", "auto_update"}: - coerced_value = self._coerce_to_bool(value) - # Handle frequency key with string validation - elif key == "update_check_frequency": - if not isinstance(value, str): - raise ValueError( - f"update_check_frequency must be a string, got {type(value).__name__}" - ) - valid_frequencies = {"daily", "weekly", "never"} - if value not in valid_frequencies: - raise ValueError( - f"Invalid frequency: {value}. Must be one of: {valid_frequencies}" - ) - coerced_value = value - else: - coerced_value = value - - self.set(key, coerced_value) - self.save() - - def _coerce_to_bool(self, value: bool | str) -> bool: - """ - Coerce a value to boolean with strict validation. - - Args: - value: Value to coerce (bool or string) + def _last_check_file(self) -> Path: + return self._cache_dir / LAST_UPDATE_CHECK_FILE - Returns: - Boolean value - - Raises: - ValueError: If value cannot be coerced to boolean - """ - if isinstance(value, bool): - return value - - if isinstance(value, str): - value_lower = value.lower().strip() - if value_lower in {"true", "1", "yes"}: - return True - elif value_lower in {"false", "0", "no"}: - return False - else: - raise ValueError( - f"Invalid boolean value: '{value}'. " - f"Must be one of: true, false, 1, 0, yes, no (case-insensitive)" - ) - - # Should never happen - raise ValueError( - f"Cannot coerce {type(value).__name__} to boolean. " - f"Expected bool or string, got {type(value).__name__}" - ) +def _normalize_libraries(value: object) -> list[LibraryDict]: + """Return the valid library entries of a loaded `libraries` setting. -def _make_library_dict(name: str, path: str, type_: str) -> LibraryDict: - """Typed constructor for `LibraryDict` to satisfy type checker.""" - return LibraryDict(name=name, path=path, type=type_) - - -def _validate_library_entry( - lib: dict[str, str] | LibraryDict, -) -> LibraryDict | None: - """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"]), + Anything other than a list becomes an empty list, and entries without a + name, path, and type are dropped; both are reported on stderr. + """ + if not isinstance(value, list): + click.echo( + f"Warning: libraries field in config was {type(value).__name__}," + " resetting to empty list", + err=True, ) - return None - - -class ConfigService: - """Service wrapper for managing KiLM configuration.""" - - def __init__(self): - """Initialize the configuration service.""" - self._config = Config() - - def get_config_file_path(self) -> Path: - """Get the path to the KiLM configuration file.""" - return self._config._get_config_file() - - def load_config(self) -> dict: - """Load the KiLM configuration.""" - return self._config._config - - def save_config(self, config: dict) -> None: - """Save the KiLM configuration.""" - self._config._config = config - self._config.save() - - def add_library(self, name: str, path: str, library_type: str) -> None: - """Add a library to the configuration.""" - self._config.add_library(name, path, library_type) - - def remove_library(self, name: str) -> None: - """Remove a library from the configuration.""" - self._config.remove_library(name) + return [] + libraries: list[LibraryDict] = [] + for entry in cast("list[object]", value): + library = _library_entry(entry) + if library is None: + click.echo(f"Warning: Skipping invalid library entry: {entry}", err=True) + else: + libraries.append(library) + return libraries - def get_libraries(self) -> list[LibraryDict]: - """Get all configured libraries.""" - return self._config.get_libraries() - def get_library_by_name(self, name: str) -> LibraryDict | None: - """Get a specific library by name.""" - libraries = self.get_libraries() - for lib in libraries: - if lib.get("name") == name: - return lib +def _library_entry(entry: object) -> LibraryDict | None: + """Return a loaded library entry as a `LibraryDict`; None if it lacks a field.""" + if not isinstance(entry, dict): return None - - def get_current_library(self) -> str | None: - """Get the current active library path.""" - return self._config.get_current_library() - - def set_current_library(self, path: str) -> None: - """Set the current active library.""" - self._config.set_current_library(path) - - def get_max_backups(self) -> int: - """Get the maximum number of backups to keep.""" - value = self._config.get("max_backups", DEFAULT_MAX_BACKUPS) - if isinstance(value, int): - return value - return DEFAULT_MAX_BACKUPS - - def set_max_backups(self, count: int) -> None: - """Set the maximum number of backups to keep.""" - self._config.set("max_backups", count) - self._config.save() + fields = cast("dict[object, object]", entry) + if not all(key in fields for key in ("name", "path", "type")): + return None + return LibraryDict( + name=str(fields["name"]), path=str(fields["path"]), type=str(fields["type"]) + ) diff --git a/kicad_lib_manager/services/install_method.py b/kicad_lib_manager/services/install_method.py new file mode 100644 index 0000000..5927959 --- /dev/null +++ b/kicad_lib_manager/services/install_method.py @@ -0,0 +1,210 @@ +"""How KiLM was installed, and the command that updates such an installation.""" + +import importlib.metadata +import os +import subprocess +import sys +from pathlib import Path, PurePath +from typing import Final, Literal + +InstallMethod = Literal[ + "pipx", "conda", "uv", "nix", "pip-venv", "homebrew", "pip", "unknown" +] +"""How KiLM was installed; decides how it can be updated.""" + +UPGRADE_TIMEOUT_SECONDS: Final = 300 +"""How long an installer may run before the update counts as failed.""" + +UPGRADE_ERROR_LINES: Final = 10 +"""Trailing lines of installer output kept to explain a failed update.""" + +_MANUAL_INSTRUCTIONS: Final[dict[InstallMethod, str]] = { + "conda": "Conda package not yet available (planned for future)", + "homebrew": "Homebrew package not yet available (planned for future)", + "nix": "nix flake update, then rebuild the configuration or profile" + " that installs kilm", + "unknown": "Update KiLM with the tool that installed it", +} +"""What to tell users of methods that `upgrade_command` cannot update.""" + + +def read_installer() -> str | None: + """Return the installer recorded for kilm (its INSTALLER file), if any.""" + try: + installer = importlib.metadata.distribution("kilm").read_text("INSTALLER") + except importlib.metadata.PackageNotFoundError: + return None + if installer is None: + return None + return installer.strip() or None + + +class InstallationDetector: + """Infers the installation method from where the interpreter lives. + + The evidence is the interpreter path, the platform, the installer recorded + in the package metadata, and the environment variables of the tools that + create such environments. Path patterns are matched on the forward-slash + form of the path, so they hold on Windows too. + """ + + def __init__( + self, + executable_path: PurePath | None = None, + platform_name: str | None = None, + *, + installer: str | None = None, + ) -> None: + """Describe one interpreter. + + Args: + executable_path: The interpreter; defaults to the running one. + platform_name: An `os.name` value; defaults to the running one. + installer: The installer recorded in kilm's metadata, if any. + """ + self.executable_path = executable_path or Path(sys.executable) + self.platform_name = platform_name or os.name + self.installer = installer + self.executable_str = self.executable_path.as_posix() + + def is_windows(self) -> bool: + """Return whether the interpreter runs on Windows.""" + return self.platform_name == "nt" + + def detect_pipx(self) -> bool: + """Return whether the interpreter is a pipx environment.""" + if any( + part in self.executable_str for part in (".local/share/pipx", "pipx/venvs") + ): + return True + return bool(os.environ.get("PIPX_HOME")) and "pipx" in self.executable_str + + def detect_conda(self) -> bool: + """Return whether the interpreter lives in a conda environment. + + An activated environment (``CONDA_DEFAULT_ENV``) is no evidence on its + own, since many shells activate conda's base environment by default. + """ + if "conda" in self.executable_str: + return True + return self._is_inside_env_dir("CONDA_PREFIX") + + def detect_uv(self) -> bool: + """Return whether the interpreter lives in a uv tools directory. + + uv keeps tool environments in ``uv/tools`` under its data directory + (``uv/data/tools`` on Windows) unless ``UV_TOOL_DIR`` moves them. The + variable alone is no evidence: it is also set where uv manages other + tools, so only an interpreter inside that directory counts. + """ + if any( + part in self.executable_str for part in ("/uv/tools/", "/uv/data/tools/") + ): + return True + return self._is_inside_env_dir("UV_TOOL_DIR") + + @staticmethod + def detect_virtual_env() -> bool: + """Return whether the running interpreter is a virtual environment.""" + if os.environ.get("VIRTUAL_ENV", ""): + return True + return sys.prefix != getattr(sys, "base_prefix", sys.prefix) + + def detect_homebrew(self) -> bool: + """Return whether the interpreter belongs to Homebrew.""" + if self.is_windows(): + return False + return self.executable_str.startswith(("/opt/homebrew/", "/usr/local/Cellar/")) + + def detect_nix(self) -> bool: + """Return whether the interpreter lives in the Nix store.""" + return self.executable_str.startswith("/nix/store/") + + def detect(self) -> InstallMethod: + """Return the installation method, most specific evidence first. + + Returns "unknown" rather than guessing when nothing identifies the + installer, so callers never suggest a command that cannot work. + """ + checks: tuple[tuple[InstallMethod, bool], ...] = ( + ("pipx", self.detect_pipx()), + ("conda", self.detect_conda()), + ("uv", self.detect_uv()), + ("nix", self.detect_nix()), + ("pip-venv", self.detect_virtual_env()), + ("homebrew", self.detect_homebrew()), + ("pip", self.installer == "pip"), + ) + return next((method for method, found in checks if found), "unknown") + + def _is_inside_env_dir(self, variable: str) -> bool: + """Return whether the interpreter is inside the directory `variable` names.""" + directory = os.environ.get(variable, "") + return bool(directory) and self.executable_path.is_relative_to( + type(self.executable_path)(directory) + ) + + +def detect_installation_method() -> InstallMethod: + """Return how the running KiLM was installed.""" + return InstallationDetector(installer=read_installer()).detect() + + +def upgrade_command( + method: InstallMethod, target_version: str | None = None +) -> list[str] | None: + """Return the command that updates kilm for an install method, as shown to users. + + Without a target version the command upgrades to the latest release; with + one it installs exactly that version, which may be a downgrade. Returns + None for methods where kilm cannot update itself. + """ + if method == "pipx": + if target_version is not None: + return ["pipx", "install", "--force", f"kilm=={target_version}"] + return ["pipx", "upgrade", "kilm"] + if method == "uv": + # `uv tool upgrade` keeps an exact pin from an earlier install and + # then succeeds without changing anything; `uv tool install` replaces + # the pin. + spec = "kilm@latest" if target_version is None else f"kilm=={target_version}" + return ["uv", "tool", "install", spec] + if method in {"pip", "pip-venv"}: + spec = "kilm" if target_version is None else f"kilm=={target_version}" + return ["pip", "install", "--upgrade", spec] + return None + + +def update_instruction(method: InstallMethod, target_version: str | None = None) -> str: + """Return what a user runs or does to update an installation by hand.""" + command = upgrade_command(method, target_version) + if command is not None: + return " ".join(command) + return _MANUAL_INSTRUCTIONS[method] + + +def run_upgrade(command: list[str]) -> str | None: + """Run an upgrade command from `upgrade_command`. + + Returns None on success, otherwise why it failed: the end of the + installer's error output, or why the installer could not run. + """ + # pip runs through this interpreter so it updates the running environment. + argv = [sys.executable, "-m", *command] if command[0] == "pip" else command + try: + result = subprocess.run( # noqa: S603 -- argv comes from upgrade_command, not user input + argv, + capture_output=True, + text=True, + timeout=UPGRADE_TIMEOUT_SECONDS, + check=False, + ) + except subprocess.TimeoutExpired: + return f"{command[0]} did not finish within {UPGRADE_TIMEOUT_SECONDS} seconds" + except OSError as exc: + return f"Could not run {command[0]}: {exc.strerror or exc}" + if result.returncode == 0: + return None + output = (result.stderr or result.stdout).strip().splitlines() + detail = "\n".join(output[-UPGRADE_ERROR_LINES:]) + return detail or f"{command[0]} exited with status {result.returncode}" diff --git a/kicad_lib_manager/services/kicad_config.py b/kicad_lib_manager/services/kicad_config.py new file mode 100644 index 0000000..2e604d4 --- /dev/null +++ b/kicad_lib_manager/services/kicad_config.py @@ -0,0 +1,90 @@ +"""Locate KiCad's per-user configuration directory.""" + +import os +import platform +import re +from collections.abc import Mapping +from pathlib import Path +from typing import Final + +from kicad_lib_manager.utils.constants import FOOTPRINT_TABLE_FILE, SYMBOL_TABLE_FILE + +RUN_KICAD_FIRST: Final = "Please run KiCad at least once before using this tool." + + +def kicad_config_root(system: str, home: Path, environ: Mapping[str, str]) -> Path: + """Return the directory holding KiCad's per-version configuration directories. + + Raises: + FileNotFoundError: The platform is not supported, or on Windows + APPDATA is not set. + """ + if system == "Darwin": + return home / "Library" / "Preferences" / "kicad" + if system == "Linux": + return home / ".config" / "kicad" + if system == "Windows": + appdata = environ.get("APPDATA", "") + if not appdata: + msg = "APPDATA environment variable not found" + raise FileNotFoundError(msg) + return Path(appdata) / "kicad" + msg = f"Unsupported platform: {system}" + raise FileNotFoundError(msg) + + +def find_kicad_config( + *, + system: str | None = None, + home: Path | None = None, + environ: Mapping[str, str] | None = None, +) -> Path: + """Return the configuration directory of the newest KiCad version. + + Version directories are those whose names contain a digit, ordered by + their numbers (10.0 after 9.0). The chosen one must hold a symbol or + footprint library table. + + Args: + system: Platform name as from `platform.system()`; defaults to it. + home: Home directory; defaults to the user's. + environ: Environment; defaults to `os.environ`. + + Raises: + FileNotFoundError: KiCad has no configuration directory, version + directory, or library table, typically because it never ran. + """ + root = kicad_config_root( + system or platform.system(), + home or Path.home(), + os.environ if environ is None else environ, + ) + if not root.exists(): + msg = f"KiCad configuration directory not found at {root}. {RUN_KICAD_FIRST}" + raise FileNotFoundError(msg) + + versions = [ + entry + for entry in root.iterdir() + if entry.is_dir() and any(c.isdigit() for c in entry.name) + ] + if not versions: + msg = f"No KiCad version directories found in {root}. {RUN_KICAD_FIRST}" + raise FileNotFoundError(msg) + + latest = max(versions, key=lambda entry: (version_key(entry.name), entry.name)) + if ( + not (latest / SYMBOL_TABLE_FILE).exists() + and not (latest / FOOTPRINT_TABLE_FILE).exists() + ): + msg = f"KiCad library tables not found in {latest}. {RUN_KICAD_FIRST}" + raise FileNotFoundError(msg) + return latest + + +def version_key(name: str) -> tuple[int, ...]: + """Return the numbers in a KiCad version directory name, for numeric ordering. + + Directories such as "9.0" and "10.0" sort as (9, 0) < (10, 0). + """ + return tuple(int(part) for part in re.findall(r"\d+", name)) diff --git a/kicad_lib_manager/services/kicad_service.py b/kicad_lib_manager/services/kicad_service.py deleted file mode 100644 index 0d0d1c4..0000000 --- a/kicad_lib_manager/services/kicad_service.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -KiCad service implementation for KiCad Library Manager. -""" - -import json -from pathlib import Path - -from ..utils.file_ops import list_configured_libraries -from .library_service import LibraryService - - -class KiCadService: - """Service for managing KiCad configuration and libraries.""" - - @staticmethod - def find_kicad_config_dir() -> Path: - return LibraryService.find_kicad_config() - - def get_environment_variables(self, config_dir: Path) -> dict[str, str]: - """Get KiCad environment variables from configuration.""" - kicad_common = config_dir / "kicad_common.json" - - if not kicad_common.exists(): - return {} - - try: - with kicad_common.open() as f: - config = json.load(f) - - if "environment" in config and "vars" in config["environment"]: - return config["environment"]["vars"] - - except Exception: - pass - - return {} - - def set_environment_variables( - self, - config_dir: Path, - env_vars: dict[str, str], - backup: bool = True, - max_backups: int = 5, - ) -> bool: - """Set KiCad environment variables.""" - # This would use the existing update_kicad_env_vars function - from ..utils.env_vars import update_kicad_env_vars - - return update_kicad_env_vars(config_dir, env_vars, backup, max_backups) - - def get_configured_libraries( - self, config_dir: Path - ) -> tuple[list[dict], list[dict]]: - """Get configured symbol and footprint libraries.""" - return list_configured_libraries(config_dir) - - def add_libraries_to_kicad( - self, - config_dir: Path, - symbol_libs: list[dict] | None = None, - footprint_libs: list[dict] | None = None, - backup: bool = True, - max_backups: int = 5, - ) -> bool: - """Add libraries to KiCad library tables.""" - # This would use the existing add_libraries function - from .library_service import LibraryService - - # TODO: Properly implement library addition with symbol_libs and footprint_libs - _ = symbol_libs, footprint_libs, max_backups # Suppress unused warnings - added_libs, changes_needed = LibraryService.add_libraries( - str(config_dir), - config_dir, - dry_run=not backup, - ) - _ = added_libs # Suppress unused warning - return changes_needed - - def get_pinned_libraries(self, config_dir: Path) -> tuple[list[str], list[str]]: - """Get pinned libraries from KiCad configuration.""" - kicad_common = config_dir / "kicad_common.json" - - if not kicad_common.exists(): - return [], [] - - try: - with kicad_common.open() as f: - config = json.load(f) - - symbol_libs = [] - footprint_libs = [] - - if "session" in config: - if "pinned_symbol_libs" in config["session"]: - symbol_libs = config["session"]["pinned_symbol_libs"] - if "pinned_fp_libs" in config["session"]: - footprint_libs = config["session"]["pinned_fp_libs"] - - return symbol_libs, footprint_libs - - except Exception: - return [], [] diff --git a/kicad_lib_manager/services/library_service.py b/kicad_lib_manager/services/library_service.py index aac6da7..15ca95e 100644 --- a/kicad_lib_manager/services/library_service.py +++ b/kicad_lib_manager/services/library_service.py @@ -1,552 +1,91 @@ -""" -Library service implementation for KiCad Library Manager. -Core functionality for managing KiCad libraries. -""" +"""Create KiLM libraries and register their contents with KiCad.""" -import os -import platform -import re from pathlib import Path -import yaml - -from ..utils.env_vars import expand_user_path, update_pinned_libraries -from ..utils.file_ops import ( - list_configured_libraries, - list_libraries, - validate_lib_table, -) -from ..utils.metadata import ( +from kicad_lib_manager.services.kicad_config import find_kicad_config +from kicad_lib_manager.services.library_table import add_libraries +from kicad_lib_manager.utils.constants import GITHUB_METADATA_FILE +from kicad_lib_manager.utils.file_ops import list_libraries +from kicad_lib_manager.utils.metadata import ( + LIBRARY_FOLDERS, + GitHubMetadata, generate_env_var_name, get_default_github_metadata, - read_github_metadata, + library_capabilities, + read_github_metadata_file, write_github_metadata, ) class LibraryService: - """Service for managing KiCad libraries.""" - - def list_libraries(self, directory: Path) -> tuple[list[str], list[str]]: - """List symbol and footprint libraries in a directory.""" - return list_libraries(str(directory)) - - def initialize_library( - self, - directory: Path, - name: str | None = None, - description: str | None = None, - env_var: str | None = None, - force: bool = False, - no_env_var: bool = False, - ) -> dict: - """Initialize a library in the given directory.""" - # Check for existing metadata - metadata = read_github_metadata(directory) - - if metadata and not force: - # Use existing metadata with potential overrides - if name: - metadata["name"] = name - if description: - metadata["description"] = description - if env_var: - metadata["env_var"] = env_var - elif no_env_var and "env_var" in metadata: - del metadata["env_var"] - - if any([name, description, env_var, no_env_var]): - metadata["updated_with"] = "kilm" - write_github_metadata(directory, metadata) - else: - # Create new metadata - metadata = get_default_github_metadata(directory) - - if name: - metadata["name"] = name - if not env_var and not no_env_var: - metadata["env_var"] = generate_env_var_name(name, "KICAD_LIB") - - if description: - metadata["description"] = description - - if env_var: - metadata["env_var"] = env_var - elif no_env_var and "env_var" in metadata: - del metadata["env_var"] - - write_github_metadata(directory, metadata) - - # Create library directory structure - self._create_library_structure(directory) - - # Update capabilities - capabilities: dict[str, bool] = { - "symbols": (directory / "symbols").exists(), - "footprints": (directory / "footprints").exists(), - "templates": (directory / "templates").exists(), - } - metadata["capabilities"] = capabilities - write_github_metadata(directory, metadata) - - return metadata - - def get_library_metadata(self, directory: Path) -> dict | None: - """Get metadata for a library directory.""" - return read_github_metadata(directory) - - def pin_libraries( - self, - symbol_libs: list[str], - footprint_libs: list[str], - kicad_config_dir: Path, - dry_run: bool = False, - max_backups: int = 5, - ) -> bool: - """Pin libraries in KiCad for quick access.""" - return update_pinned_libraries( - kicad_config_dir, - symbol_libs=symbol_libs, - footprint_libs=footprint_libs, - dry_run=dry_run, - max_backups=max_backups, - ) - - def unpin_libraries( - self, - symbol_libs: list[str], - footprint_libs: list[str], - kicad_config_dir: Path, - dry_run: bool = False, - max_backups: int = 5, - ) -> bool: - """Unpin libraries in KiCad.""" - # This would need to be implemented in utils/env_vars.py - # For now, we'll return False as it's not implemented - # TODO: Implement unpin functionality - _ = symbol_libs, footprint_libs, kicad_config_dir, dry_run, max_backups - return False - - def _create_library_structure(self, directory: Path) -> tuple[list[str], list[str]]: - """Create the required directory structure for a library.""" - required_folders = ["symbols", "footprints", "templates"] - existing_folders = [] - created_folders = [] - - for folder in required_folders: - folder_path = directory / folder - if folder_path.exists(): - existing_folders.append(folder) - else: - folder_path.mkdir(parents=True, exist_ok=True) - created_folders.append(folder) - - return existing_folders, created_folders - - @staticmethod - def add_libraries( - kicad_lib_dir: str, - kicad_config: Path, - kicad_3d_dir: str | None = None, - additional_3d_dirs: dict[str, str] | None = None, - dry_run: bool = False, - ) -> tuple[set[str], bool]: - """ - Add KiCad libraries to the configuration. - - Args: - kicad_lib_dir: Path to the KiCad library directory - kicad_config: Path to the KiCad configuration directory - kicad_3d_dir: Path to the KiCad 3D models directory (optional) - additional_3d_dirs: Dictionary of additional 3D model directories with their - environment variable names as keys (optional) - dry_run: If True, don't actually make any changes - - Returns: - Tuple of (set of libraries added, whether changes were needed) - - Raises: - FileNotFoundError: If the library directory does not exist - ValueError: If the library directory does not contain symbols or footprints - """ - # Check if library directory exists - # First expand any environment variables in the path - if kicad_lib_dir.startswith("${") and kicad_lib_dir.endswith("}"): - env_var = kicad_lib_dir[2:-1] - if env_var in os.environ: - kicad_lib_dir = os.environ[env_var] - else: - raise FileNotFoundError(f"Environment variable {env_var} not found") - # Check if it's NOT an absolute path (Unix/Windows) or UNC path (Windows) - # and treat it as a potential environment variable name if it's not. - elif not ( - kicad_lib_dir.startswith("/") - or ( - len(kicad_lib_dir) > 2 and kicad_lib_dir[1] == ":" - ) # Check for C: style paths - or ( - kicad_lib_dir.startswith("\\\\") - ) # Check for UNC paths like \\server\share - ): - # If it's an environment variable name without ${} - if kicad_lib_dir in os.environ: - kicad_lib_dir = os.environ[kicad_lib_dir] - else: - raise FileNotFoundError( - f"Environment variable {kicad_lib_dir} not found" - ) - - # Now expand any user paths - kicad_lib_dir = expand_user_path(kicad_lib_dir) - lib_dir = Path(kicad_lib_dir) - if not lib_dir.exists(): - raise FileNotFoundError( - f"KiCad library directory not found: {kicad_lib_dir}" - ) - - # Check if 3D models directory exists - if kicad_3d_dir: - kicad_3d_dir = expand_user_path(kicad_3d_dir) - models_dir = Path(kicad_3d_dir) - if not models_dir.exists(): - raise FileNotFoundError( - f"KiCad 3D models directory not found: {kicad_3d_dir}" - ) - - # Check if additional 3D model directories exist - all_3d_dirs = {} - if kicad_3d_dir: - all_3d_dirs["KICAD_3D_LIB"] = kicad_3d_dir - - if additional_3d_dirs: - for env_var, path in additional_3d_dirs.items(): - path = expand_user_path(path) - dir_path = Path(path) - if not dir_path.exists(): - print(f"Warning: 3D models directory not found: {path} (skipping)") - continue - - # Only add if this is a different path than the main 3D models directory - if kicad_3d_dir and os.path.normpath(path) == os.path.normpath( - kicad_3d_dir - ): - continue - - all_3d_dirs[env_var] = path - - # Get list of available libraries - symbols, footprints = list_libraries(kicad_lib_dir) - if not symbols and not footprints: - raise ValueError(f"No libraries found in {kicad_lib_dir}") - - # Check if library tables exist - sym_table = kicad_config / "sym-lib-table" - fp_table = kicad_config / "fp-lib-table" - - # Get existing libraries - sym_libs, fp_libs = list_configured_libraries(kicad_config) - sym_lib_names = {lib["name"] for lib in sym_libs} - fp_lib_names = {lib["name"] for lib in fp_libs} - - # Check for new libraries - new_symbols = [lib for lib in symbols if lib not in sym_lib_names] - new_footprints = [lib for lib in footprints if lib not in fp_lib_names] - - # Generate variable references for 3D model paths - env_var_refs = {} - for env_var, path in all_3d_dirs.items(): - # Convert to format KiCad expects: ${ENV_VAR} - env_var_refs[path] = f"${{{env_var}}}" - - # Add new libraries to symbol table - sym_changes_needed = False - new_sym_entries = [] - for lib in new_symbols: - uri = LibraryService.format_uri(kicad_lib_dir, lib, "symbols") - - # Add the library with UTF-8 encoded description - new_sym_entries.append( - { - "name": lib, - "uri": uri, - "options": "", - "description": LibraryService.get_library_description( - "symbols", lib, kicad_lib_dir - ) - .encode("utf-8") - .decode("utf-8"), - } - ) - sym_changes_needed = True - - # Add new libraries to footprint table - fp_changes_needed = False - new_fp_entries = [] - for lib in new_footprints: - uri = LibraryService.format_uri(kicad_lib_dir, lib, "footprints") - - # Add the library with UTF-8 encoded description - new_fp_entries.append( - { - "name": lib, - "uri": uri, - "options": "", - "description": LibraryService.get_library_description( - "footprints", lib, kicad_lib_dir - ) - .encode("utf-8") - .decode("utf-8"), - } - ) - fp_changes_needed = True - - # Only make changes if needed - changes_needed = sym_changes_needed or fp_changes_needed - if changes_needed and not dry_run: - # Add new entries to symbol table - if sym_changes_needed: - LibraryService.add_entries_to_table(sym_table, new_sym_entries) + """Library operations behind the init, list, pin, setup, status, and unpin commands. - # Add new entries to footprint table - if fp_changes_needed: - LibraryService.add_entries_to_table(fp_table, new_fp_entries) + `add_libraries` and `find_kicad_config` are the module functions of + library_table and kicad_config. They stay class attributes because + commands call them through this class and tests patch them here. + """ - # Return the set of added libraries - added_libraries = set(new_symbols + new_footprints) - return added_libraries, changes_needed + add_libraries = staticmethod(add_libraries) + find_kicad_config = staticmethod(find_kicad_config) @staticmethod - def _kicad_version_key(version_dir: Path) -> tuple[int, ...]: - """Return a numeric version key for KiCad configuration directories.""" - return tuple(int(part) for part in re.findall(r"\d+", version_dir.name)) - - @staticmethod - def find_kicad_config() -> Path: - """ - Find the KiCad configuration directory for the current platform - - Returns: - Path to the KiCad configuration directory + def list_libraries(directory: Path) -> tuple[list[str], list[str]]: + """Return the symbol and footprint library names in a KiLM library. Raises: - FileNotFoundError: If KiCad configuration directory is not found + FileNotFoundError: directory does not exist. """ - system = platform.system() - - if system == "Darwin": # macOS - config_dir = Path.home() / "Library" / "Preferences" / "kicad" - elif system == "Linux": - config_dir = Path.home() / ".config" / "kicad" - elif system == "Windows": - appdata = os.environ.get("APPDATA") - if not appdata: - raise FileNotFoundError("APPDATA environment variable not found") - config_dir = Path(appdata) / "kicad" - else: - raise FileNotFoundError(f"Unsupported platform: {system}") - - if not config_dir.exists(): - raise FileNotFoundError( - f"KiCad configuration directory not found at {config_dir}. " - "Please run KiCad at least once before using this tool." - ) - - # Find the most recent KiCad version directory - version_dirs = [d for d in config_dir.iterdir() if d.is_dir()] - if not version_dirs: - raise FileNotFoundError( - f"No KiCad version directories found in {config_dir}. " - "Please run KiCad at least once before using this tool." - ) - - # Sort directories by version number (assuming directories with numbers are version dirs) - version_dirs = [d for d in version_dirs if any(c.isdigit() for c in d.name)] - if not version_dirs: - raise FileNotFoundError( - f"No KiCad version directories found in {config_dir}. " - "Please run KiCad at least once before using this tool." - ) - - latest_dir = max( - version_dirs, - key=lambda d: (LibraryService._kicad_version_key(d), d.name), - ) - - # Check for required files - sym_table = latest_dir / "sym-lib-table" - fp_table = latest_dir / "fp-lib-table" - - if not sym_table.exists() and not fp_table.exists(): - raise FileNotFoundError( - f"KiCad library tables not found in {latest_dir}. " - "Please run KiCad at least once before using this tool." - ) - - return latest_dir - - @staticmethod - def get_library_description( - lib_type: str, lib_name: str, kicad_lib_dir: str - ) -> str: - """ - Get a description for a library from the YAML file or generate a default one - - Args: - lib_type: Either 'symbols' or 'footprints' - lib_name: The name of the library - kicad_lib_dir: The KiCad library directory - - Returns: - A description for the library - """ - yaml_file = Path(kicad_lib_dir) / "library_descriptions.yaml" - - # Check if YAML file exists - if yaml_file.exists(): - try: - with yaml_file.open() as f: - data = yaml.safe_load(f) - - if ( - data - and isinstance(data, dict) - and lib_type in data - and isinstance(data[lib_type], dict) - and lib_name in data[lib_type] - ): - return data[lib_type][lib_name] - except Exception: - pass - - # Default description if YAML file doesn't exist or doesn't contain the library - if lib_type == "symbols": - return f"{lib_name} symbol library" - else: - return f"{lib_name} footprint library" + return list_libraries(str(directory)) @staticmethod - def format_uri(base_path: str, lib_name: str, lib_type: str) -> str: - """ - Format a URI for a KiCad library. - - Args: - base_path: The base path to the library directory - lib_name: The name of the library - lib_type: The type of library (symbols or footprints) + def initialize_library( # noqa: PLR0913 -- one parameter per init command option + directory: Path, + *, + name: str | None = None, + description: str | None = None, + env_var: str | None = None, + force: bool = False, + no_env_var: bool = False, + ) -> GitHubMetadata: + """Make directory a KiLM library and return its metadata. - Returns: - The formatted URI string + Existing metadata is kept unless force is set, with name, + description, and env_var (when given) replacing its fields. New + metadata derives the environment variable from name unless env_var + or no_env_var is given. The library folders are created, and the + capabilities and metadata file reflect them. Without force, entries + the metadata cannot hold (see `read_github_metadata_file`) are kept. Raises: - ValueError: If base_path is empty, lib_type is invalid, or path format is invalid + OSError: A folder or the metadata file cannot be written. """ - if not base_path: - raise ValueError("Base path cannot be empty") - - if lib_type not in ["symbols", "footprints"]: - raise ValueError(f"Invalid library type: {lib_type}") - - # Validate ${...} format if present - if base_path.startswith("${") and not base_path.endswith("}"): - raise ValueError(f"Invalid environment variable format: {base_path}") - - # Helper function to check if a path is absolute - def is_absolute_path(path: str) -> bool: - return ( - path.startswith("/") # Unix-style - or path.startswith("\\") # Windows-style with backslash - or (len(path) > 1 and path[1] == ":") # Windows-style with drive letter - ) - - # Normalize path separators to forward slashes first - base_path = base_path.replace("\\", "/") - - # Check if the path is already in ${...} format - if base_path.startswith("${") and base_path.endswith("}"): - # Extract the path from inside the curly braces - path = base_path[2:-1] - if is_absolute_path(path): - # If it's an absolute path, remove the ${...} wrapper - base_path = path - # Otherwise, keep the ${...} wrapper for environment variables - else: - # If it's not in ${...} format, check if it's an absolute path - if not is_absolute_path(base_path): - # If it's not an absolute path, treat it as an environment variable - base_path = f"${{{base_path}}}" - - # Construct the URI based on library type - if lib_type == "symbols": - return f"{base_path}/symbols/{lib_name}.kicad_sym" + overrides = (name, description, env_var) + existing = read_github_metadata_file(directory) + unsupported: dict[object, object] = {} + if force or existing is None or not (existing.metadata or existing.unsupported): + metadata = get_default_github_metadata(directory) + if name is not None and env_var is None and not no_env_var: + metadata["env_var"] = generate_env_var_name(name, "KICAD_LIB") else: - return f"{base_path}/footprints/{lib_name}.pretty" - - @staticmethod - def add_entries_to_table(table_path: Path, entries: list[dict[str, str]]) -> None: - """ - Add entries to a KiCad library table file - - Args: - table_path: Path to the library table - entries: List of entries to add - """ - # Make sure the table exists and has a valid format - validate_lib_table(table_path, False) - - # Read existing content, ensuring UTF-8 encoding - with table_path.open(encoding="utf-8") as f: - content = f.read() - - # Find the last proper closing parenthesis of the table - closing_paren_index = -1 - lines = content.splitlines() - for i in range(len(lines) - 1, -1, -1): - if lines[i].strip() == ")": - closing_paren_index = i - break - - if closing_paren_index == -1: - raise ValueError( - f"Could not find closing parenthesis in library table: {table_path}" - ) - - # Insert new entries before the closing parenthesis - new_content = "" - for i, line in enumerate(lines): - if i == closing_paren_index: - # Insert entries before the closing parenthesis line - for entry in entries: - # Process the URI to make sure it's properly formatted - uri = entry["uri"] - - # Check if URI starts with ${/ or ${\ - this indicates an improperly formatted path - if uri.startswith("${/") or uri.startswith("${\\"): - # Extract the path from inside the curly braces using a more robust method - try: - # Find the first { and last } - start = uri.find("{") - end = uri.rfind("}") - if start != -1 and end != -1 and end > start: - path = uri[start + 1 : end] - # Replace with the actual path without environment variable syntax - uri = path + uri[end + 1 :] - except Exception: - # If there's any error in processing, keep the original URI - pass - - # Format the entry with proper escaping - entry_str = ( - f" (lib " - f'(name "{entry["name"]}")' - f'(type "KiCad")' - f'(uri "{uri}")' - f'(options "{entry["options"]}")' - f'(descr "{entry["description"]}"))\n' - ) - new_content += entry_str - - new_content += line + "\n" + metadata, unsupported = existing + if no_env_var or any(value is not None for value in overrides): + metadata["updated_with"] = "kilm" - # Write updated content, ensuring UTF-8 encoding - with table_path.open("w", encoding="utf-8") as f: - f.write(new_content) + if name is not None: + metadata["name"] = name + if description is not None: + metadata["description"] = description + if env_var is not None: + metadata["env_var"] = env_var + elif no_env_var: + metadata.pop("env_var", None) + unsupported.pop("env_var", None) + + for folder in LIBRARY_FOLDERS: + (directory / folder).mkdir(parents=True, exist_ok=True) + # Every folder exists now, so every capability is set. + metadata["capabilities"] = library_capabilities(directory) + if not write_github_metadata(directory, metadata, unsupported): + msg = f"Could not write {GITHUB_METADATA_FILE} in {directory}" + raise OSError(msg) + return metadata diff --git a/kicad_lib_manager/services/library_table.py b/kicad_lib_manager/services/library_table.py new file mode 100644 index 0000000..e0e7dbc --- /dev/null +++ b/kicad_lib_manager/services/library_table.py @@ -0,0 +1,262 @@ +"""Add a KiLM library's symbols and footprints to KiCad's library tables.""" + +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"yaml"} + +import os +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Literal, cast + +import yaml + +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import FOOTPRINT_TABLE_FILE, SYMBOL_TABLE_FILE +from kicad_lib_manager.utils.env_vars import expand_user_path +from kicad_lib_manager.utils.file_ops import ( + list_configured_libraries, + list_libraries, + validate_lib_table, + write_atomically, +) + +LibraryKind = Literal["symbols", "footprints"] +"""The library subdirectory of a KiLM library, and so the KiCad table it goes in.""" + +TABLE_FILES: Final[dict[LibraryKind, str]] = { + "symbols": SYMBOL_TABLE_FILE, + "footprints": FOOTPRINT_TABLE_FILE, +} + +LIBRARY_SUFFIXES: Final[dict[LibraryKind, str]] = { + "symbols": ".kicad_sym", + "footprints": ".pretty", +} + +DESCRIPTIONS_FILE: Final = "library_descriptions.yaml" +"""Optional file in a KiLM library mapping kind and library name to a description.""" + +DEFAULT_DESCRIPTIONS: Final[dict[LibraryKind, str]] = { + "symbols": "{name} symbol library", + "footprints": "{name} footprint library", +} + + +@dataclass(frozen=True) +class TableEntry: + """A library to add to a KiCad library table.""" + + name: str + uri: str + description: str + options: str = "" + + def to_sexpr(self) -> str: + """Return the `(lib ...)` line for this entry, indented for a table.""" + return ( + f" (lib (name {_quote(self.name)})" + '(type "KiCad")' + f"(uri {_quote(_unwrap_absolute(self.uri))})" + f"(options {_quote(self.options)})" + f"(descr {_quote(self.description)}))" + ) + + +def add_libraries( + kicad_lib_dir: str, + kicad_config: Path, + *, + kicad_3d_dir: str | None = None, + additional_3d_dirs: Mapping[str, str] | None = None, + dry_run: bool = False, +) -> tuple[set[str], bool]: + """Add the libraries in a KiLM library that KiCad's tables do not list yet. + + Args: + kicad_lib_dir: The KiLM library: a path, an environment variable + name, or `${NAME}`. Anything that is not an absolute path is + looked up as an environment variable. + kicad_config: KiCad's configuration directory, holding the tables. + kicad_3d_dir: Main 3D model directory; it must exist. + additional_3d_dirs: Further 3D model directories by environment + variable name; missing ones are reported and skipped. + dry_run: Report what would be added without writing. + + Returns: + The names of the libraries added (or to add), and whether any are. + + Raises: + FileNotFoundError: The environment variable is not set, or the + library or main 3D model directory does not exist. + ValueError: The library has neither symbols nor footprints, or a + table is not a valid KiCad library table. + """ + library_dir = _resolve_library_dir(kicad_lib_dir, os.environ) + _check_3d_dirs(kicad_3d_dir, additional_3d_dirs or {}) + + symbols, footprints = list_libraries(library_dir) + if not symbols and not footprints: + msg = f"No libraries found in {library_dir}" + raise ValueError(msg) + + configured_symbols, configured_footprints = list_configured_libraries(kicad_config) + descriptions = _read_descriptions(Path(library_dir)) + added: set[str] = set() + kinds: tuple[tuple[LibraryKind, list[str], set[str]], ...] = ( + ("symbols", symbols, {entry["name"] for entry in configured_symbols}), + ("footprints", footprints, {entry["name"] for entry in configured_footprints}), + ) + for kind, available, configured in kinds: + entries = [ + TableEntry( + name=name, + uri=format_uri(library_dir, name, kind), + description=_description(descriptions, kind, name), + ) + for name in available + if name not in configured + ] + if entries and not dry_run: + add_entries_to_table(kicad_config / TABLE_FILES[kind], entries) + added.update(entry.name for entry in entries) + return added, bool(added) + + +def _resolve_library_dir(kicad_lib_dir: str, environ: Mapping[str, str]) -> str: + """Return the existing library directory that kicad_lib_dir names.""" + if kicad_lib_dir.startswith("${") and kicad_lib_dir.endswith("}"): + variable: str | None = kicad_lib_dir[2:-1] + elif _is_absolute(kicad_lib_dir): + variable = None + else: + variable = kicad_lib_dir + if variable is not None: + value = environ.get(variable) + if value is None: + msg = f"Environment variable {variable} not found" + raise FileNotFoundError(msg) + kicad_lib_dir = value + + library_dir = expand_user_path(kicad_lib_dir) + if not Path(library_dir).exists(): + msg = f"KiCad library directory not found: {library_dir}" + raise FileNotFoundError(msg) + return library_dir + + +def _check_3d_dirs(main: str | None, additional: Mapping[str, str]) -> None: + """Require the main 3D model directory; warn about missing additional ones.""" + if main is not None: + models_dir = expand_user_path(main) + if not Path(models_dir).exists(): + msg = f"KiCad 3D models directory not found: {models_dir}" + raise FileNotFoundError(msg) + for path in additional.values(): + models_dir = expand_user_path(path) + if not Path(models_dir).exists(): + get_console(stderr=True).print( + f"Warning: 3D models directory not found: {models_dir} (skipping)", + markup=False, + highlight=False, + ) + + +def _read_descriptions(library_dir: Path) -> dict[str, object]: + """Return the library's descriptions file as a mapping; empty if unusable.""" + try: + with (library_dir / DESCRIPTIONS_FILE).open(encoding="utf-8") as f: + data: object = yaml.safe_load(f) + except (OSError, UnicodeDecodeError, yaml.YAMLError): + return {} + return cast("dict[str, object]", data) if isinstance(data, dict) else {} + + +def _description( + descriptions: Mapping[str, object], kind: LibraryKind, name: str +) -> str: + """Return the described text for a library, or a default naming its kind.""" + of_kind = descriptions.get(kind) + if isinstance(of_kind, dict): + text = cast("dict[object, object]", of_kind).get(name) + if text is not None: + return str(text) + return DEFAULT_DESCRIPTIONS[kind].format(name=name) + + +def format_uri(base_path: str, lib_name: str, lib_type: str) -> str: + """Return the table URI of a library inside base_path. + + Backslashes become forward slashes. An absolute base_path (also inside + `${...}`) is used as is; anything else is an environment variable and + is written as `${NAME}`. + + Raises: + ValueError: base_path is empty or an unclosed `${`, or lib_type is + neither "symbols" nor "footprints". + """ + if not base_path: + msg = "Base path cannot be empty" + raise ValueError(msg) + if lib_type not in LIBRARY_SUFFIXES: + msg = f"Invalid library type: {lib_type}" + raise ValueError(msg) + if base_path.startswith("${") and not base_path.endswith("}"): + msg = f"Invalid environment variable format: {base_path}" + raise ValueError(msg) + + base_path = base_path.replace("\\", "/") + if base_path.startswith("${"): + inner = base_path[2:-1] + if _is_absolute(inner): + base_path = inner + elif not _is_absolute(base_path): + base_path = f"${{{base_path}}}" + kind = cast("LibraryKind", lib_type) + return f"{base_path}/{kind}/{lib_name}{LIBRARY_SUFFIXES[kind]}" + + +def _is_absolute(path: str) -> bool: + """Return whether path is absolute on Unix or Windows (drive, root, or UNC).""" + return path.startswith(("/", "\\")) or (len(path) > 1 and path[1] == ":") + + +def add_entries_to_table(table_path: Path, entries: Sequence[TableEntry]) -> None: + """Insert entries before the closing parenthesis of a library table. + + The table is replaced atomically. + + Raises: + ValueError: The table is missing, is not a library table, or has no + line holding only its closing parenthesis. + """ + validate_lib_table(table_path, create_if_missing=False) + lines = table_path.read_text(encoding="utf-8").splitlines() + closing = next( + (i for i in range(len(lines) - 1, -1, -1) if lines[i].strip() == ")"), None + ) + if closing is None: + msg = f"Could not find closing parenthesis in library table: {table_path}" + raise ValueError(msg) + lines[closing:closing] = [entry.to_sexpr() for entry in entries] + write_atomically(table_path, ("\n".join(lines) + "\n").encode("utf-8")) + + +def _unwrap_absolute(uri: str) -> str: + """Remove a `${...}` wrapper around an absolute path, which KiCad cannot resolve.""" + if uri.startswith(("${/", "${\\")): + end = uri.rfind("}") + if end != -1: + return uri[2:end] + uri[end + 1 :] + return uri + + +def _quote(value: str) -> str: + """Quote value as a KiCad S-expression string, as KiCad's own writer does.""" + escaped = ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + ) + return f'"{escaped}"' diff --git a/kicad_lib_manager/services/pypi_version.py b/kicad_lib_manager/services/pypi_version.py new file mode 100644 index 0000000..32a23e4 --- /dev/null +++ b/kicad_lib_manager/services/pypi_version.py @@ -0,0 +1,143 @@ +"""Latest-release lookup on PyPI, cached on disk for a day.""" + +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"requests"} + +import json +import time +from dataclasses import asdict, dataclass +from http import HTTPStatus +from pathlib import Path +from typing import Final + +import requests + +from kicad_lib_manager.services.user_paths import user_cache_dir + +CACHE_TTL_SECONDS: Final = 24 * 60 * 60 +"""How long a cached answer is returned without asking PyPI.""" + +REQUEST_TIMEOUT_SECONDS: Final = 10 +"""How long a PyPI request may take before the check gives up.""" + + +VERSION_CHECK_FILE: Final = "version_check.json" +"""Cache file, in the user cache directory, holding the last PyPI answer.""" + + +@dataclass(frozen=True) +class CachedVersion: + """The last answer from PyPI and the ETag that identifies it.""" + + version: str + etag: str | None + timestamp: float + + @staticmethod + def from_json(data: object) -> "CachedVersion | None": + """Parse a cache file's content; None when it is not a cache entry.""" + if not isinstance(data, dict): + return None + version = data.get("version") + etag = data.get("etag") + timestamp = data.get("timestamp") + if not isinstance(version, str) or not isinstance(timestamp, int | float): + return None + etag = etag if isinstance(etag, str) else None + return CachedVersion(version, etag, float(timestamp)) + + def is_fresh(self) -> bool: + """Return whether the entry is young enough to answer without PyPI.""" + return time.time() - self.timestamp < CACHE_TTL_SECONDS + + +class PyPIVersionChecker: + """PyPI client for the latest release of a package, cached for a day. + + A cached answer younger than `CACHE_TTL_SECONDS` is returned without a + request. Otherwise PyPI is asked with the cached ETag, so an unchanged + release costs a 304 answer. + """ + + def __init__( + self, + package_name: str, + version: str = "unknown", + *, + cache_file: Path | None = None, + ) -> None: + """Prepare a checker; nothing is read or requested yet. + + Args: + package_name: The PyPI project to look up. + version: The running version, sent in the User-Agent. + cache_file: Where answers are cached; defaults to the user cache. + """ + self.base_url = f"https://pypi.org/pypi/{package_name}/json" + self.cache_file = cache_file or user_cache_dir() / VERSION_CHECK_FILE + self.user_agent = f"KiLM/{version} (+https://github.com/barisgit/KiLM)" + + def check_latest_version(self, *, use_cache: bool = True) -> str | None: + """Return the latest released version, or None when it is unknown. + + With `use_cache` a fresh cached answer is returned without a request. + Network and response errors return None, even when an older answer + is cached, and never raise, so an offline machine only loses the + update notice. + """ + cached = self._load_cache() + if use_cache and cached is not None and cached.is_fresh(): + return cached.version + + headers = {"User-Agent": self.user_agent} + if cached is not None and cached.etag is not None: + headers["If-None-Match"] = cached.etag + try: + response = requests.get( + self.base_url, headers=headers, timeout=REQUEST_TIMEOUT_SECONDS + ) + answer = self._read_response(response, cached) + except (requests.RequestException, ValueError): + return None + if answer is None: + return None + self._save_cache(answer) + return answer.version + + @staticmethod + def _read_response( + response: requests.Response, cached: CachedVersion | None + ) -> CachedVersion | None: + """Turn a PyPI answer into a cache entry; None when it holds no version. + + Raises: + ValueError: The body is not JSON. + """ + if response.status_code == HTTPStatus.NOT_MODIFIED: + if cached is None: + return None + return CachedVersion(cached.version, cached.etag, time.time()) + if response.status_code != HTTPStatus.OK: + return None + body = response.json() + info = body.get("info") if isinstance(body, dict) else None + version = info.get("version") if isinstance(info, dict) else None + if not isinstance(version, str): + return None + return CachedVersion(version, response.headers.get("ETag"), time.time()) + + def _load_cache(self) -> CachedVersion | None: + """Return the cache entry of any age; None when absent or unreadable.""" + try: + with self.cache_file.open(encoding="utf-8") as f: + return CachedVersion.from_json(json.load(f)) + except (OSError, ValueError): + return None + + def _save_cache(self, entry: CachedVersion) -> None: + """Write the cache entry; a cache that cannot be written is skipped.""" + try: + self.cache_file.parent.mkdir(parents=True, exist_ok=True) + self.cache_file.write_text(json.dumps(asdict(entry)), encoding="utf-8") + except OSError: + return diff --git a/kicad_lib_manager/services/update_service.py b/kicad_lib_manager/services/update_service.py index 6f08ff7..ff9885c 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -1,487 +1,175 @@ -""" -Auto-update functionality for KiLM. -Handles installation method detection, PyPI integration, and update execution. -""" +"""Checking for and installing new KiLM releases.""" -import json -import os -import subprocess -import sys -import time -from pathlib import Path, PurePath -from typing import Any +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"packaging.version"} -import requests -from packaging.version import InvalidVersion, Version - - -class InstallationDetector: - """Platform-aware installation method detector.""" - - def __init__( - self, - executable_path: PurePath | None = None, - platform_name: str | None = None, - ): - self.executable_path = executable_path or Path(sys.executable) - self.platform_name = platform_name or os.name - # Forward slashes on every platform so the path patterns below - # (".local/share/pipx", "uv/tools", ...) also match Windows paths. - self.executable_str = self.executable_path.as_posix() - - def is_windows(self) -> bool: - """Check if running on Windows.""" - return self.platform_name == "nt" - - def is_unix_like(self) -> bool: - """Check if running on Unix-like system.""" - return not self.is_windows() - - def detect_pipx(self) -> bool: - """Detect pipx installation.""" - # Check for pipx path patterns - pipx_patterns = [".local/share/pipx", "pipx/venvs"] - if any(part in self.executable_str for part in pipx_patterns): - return True - - # Check for pipx environment variable - pipx_home = os.environ.get("PIPX_HOME") - return bool(pipx_home and "pipx" in self.executable_str) - - def detect_conda(self) -> bool: - """Detect conda installation.""" - return bool( - os.environ.get("CONDA_DEFAULT_ENV") or "conda" in self.executable_str - ) - - def detect_uv(self) -> bool: - """Detect UV tool installation.""" - # Check for explicit UV path patterns in the executable path - most reliable - uv_path_patterns = any( - part in self.executable_str for part in [".local/share/uv", "uv/tools"] - ) - - # More specific check: only if executable is actually within uv tools directory - uv_tools_executable = False - if self.is_unix_like(): - uv_tools_path = Path.home() / ".local" / "share" / "uv" / "tools" - # Only consider it uv if the executable is actually within the uv tools directory - try: - self.executable_path.relative_to(uv_tools_path) - uv_tools_executable = True - except ValueError: - # executable_path is not within uv_tools_path - uv_tools_executable = False - - # Check environment variables only if no explicit path evidence - # This prevents false positives when uv is installed system-wide but we're in a venv - uv_env_vars = False - if not uv_path_patterns and not uv_tools_executable: - uv_env_vars = bool( - os.environ.get("UV_TOOL_DIR") or os.environ.get("UV_TOOL_BIN_DIR") - ) - - return uv_path_patterns or uv_tools_executable or uv_env_vars - - def detect_virtual_env(self) -> bool: - """Detect virtual environment (pip in venv).""" - # Check VIRTUAL_ENV environment variable - if os.environ.get("VIRTUAL_ENV"): - return True - - # Check sys.prefix vs base_prefix - base_prefix = getattr(sys, "base_prefix", sys.prefix) - return sys.prefix != base_prefix - - def detect_homebrew(self) -> bool: - """Detect homebrew installation (macOS/Linux).""" - if self.is_windows(): - return False - - homebrew_paths = ["/opt/homebrew/", "/usr/local/Cellar/"] - return any(self.executable_str.startswith(path) for path in homebrew_paths) - - def detect(self) -> str: - """ - Detect installation method with priority order. - Returns: 'pipx' | 'conda' | 'uv' | 'pip-venv' | 'homebrew' | 'pip' - """ - # Priority order matters - more specific first - if self.detect_pipx(): - return "pipx" - - if self.detect_conda(): - return "conda" +from typing import NamedTuple, Protocol, TypedDict - if self.detect_uv(): - return "uv" - - if self.detect_virtual_env(): - return "pip-venv" - - if self.detect_homebrew(): - return "homebrew" - - return "pip" - - -# TODO: Add strong typing -def detect_installation_method() -> str: - """ - Detect how KiLM was installed to determine appropriate update strategy. - Returns: 'pipx' | 'pip' | 'pip-venv' | 'uv' | 'conda' | 'homebrew' - """ - detector = InstallationDetector() - return detector.detect() - - -class PyPIVersionChecker: - """Responsible PyPI API client with caching and proper headers.""" - - def __init__(self, package_name: str, version: str = "unknown"): - self.package_name = package_name - self.base_url = f"https://pypi.org/pypi/{package_name}/json" - self.cache_file = Path.home() / ".cache" / "kilm" / "version_check.json" - self.user_agent = f"KiLM/{version} (+https://github.com/barisgit/KiLM)" - - def check_latest_version(self) -> str | None: - """ - Check latest version from PyPI with caching and rate limiting. - Returns None if check fails or is rate limited. - """ - try: - headers = {"User-Agent": self.user_agent} - - # Use cached ETag if available - cached_data = self._load_cache() - if cached_data is not None and "etag" in cached_data: - headers["If-None-Match"] = cached_data["etag"] +from packaging.version import InvalidVersion, Version +from rich.panel import Panel - response = requests.get(self.base_url, headers=headers, timeout=10) +from kicad_lib_manager.services.install_method import ( + InstallMethod, + detect_installation_method, + run_upgrade, + update_instruction, + upgrade_command, +) +from kicad_lib_manager.services.pypi_version import PyPIVersionChecker +from kicad_lib_manager.utils.console import get_console - if response.status_code == 304: # Not Modified - return cached_data.get("version") if cached_data else None - if response.status_code == 200: - data = response.json() - latest_version = data["info"]["version"] +class LatestVersionSource(Protocol): + """Where the latest release is looked up; `PyPIVersionChecker` in production.""" - # Cache response with ETag - self._save_cache( - { - "version": latest_version, - "etag": response.headers.get("ETag"), - "timestamp": time.time(), - } - ) + def check_latest_version(self, *, use_cache: bool = True) -> str | None: + """Return the latest released version, or None when it is unknown.""" + ... - return latest_version - except (requests.RequestException, KeyError, json.JSONDecodeError): - # Fail silently - don't block CLI functionality - pass +class UpdateStatus(TypedDict): + """Result of an update check.""" - return None + has_update: bool + """Whether a release newer than the installed version exists.""" + current_version: str + latest_version: str | None + """None when no fresh cached answer was used and the PyPI lookup failed.""" + method: InstallMethod + supports_auto_update: bool - def _load_cache(self) -> dict | None: - """Load cached version data.""" - if self.cache_file.exists(): - try: - with Path(self.cache_file).open() as f: - data = json.load(f) - # Cache valid for 24 hours - if time.time() - data.get("timestamp", 0) < 86400: - return data - except (json.JSONDecodeError, KeyError): - pass - return None - def _save_cache(self, data: dict): - """Save version data to cache.""" - self.cache_file.parent.mkdir(parents=True, exist_ok=True) - with Path(self.cache_file).open("w") as f: - json.dump(data, f) +class UpdateResult(NamedTuple): + """Outcome of an installation attempt.""" + success: bool + message: str + """On failure, why it failed and how to update by hand.""" -def update_via_pipx() -> bool: - """Update KiLM via pipx. Most reliable method for CLI tools.""" - try: - result = subprocess.run( - ["pipx", "upgrade", "kilm"], capture_output=True, text=True, timeout=300 - ) - return result.returncode == 0 - except (subprocess.TimeoutExpired, FileNotFoundError): - return False - -def update_via_pip() -> bool: - """Update KiLM via pip.""" +def is_newer_version(latest_version: str, current_version: str) -> bool: + """Return whether `latest_version` is newer; False if either is no version.""" try: - # Use same Python interpreter that's running KiLM - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "--upgrade", "kilm"], - capture_output=True, - text=True, - timeout=300, - ) - return result.returncode == 0 - except (subprocess.TimeoutExpired, FileNotFoundError): + return Version(latest_version) > Version(current_version) + except InvalidVersion: return False -def update_via_uv() -> bool: - """Update KiLM via uv.""" - try: - result = subprocess.run( - ["uv", "tool", "upgrade", "kilm"], - capture_output=True, - text=True, - timeout=300, - ) - return result.returncode == 0 - except (subprocess.TimeoutExpired, FileNotFoundError): - return False +class UpdateService: + """Checks PyPI for new KiLM releases and installs them. + The installation method is detected once, at construction, and decides + which installer command an update runs. + """ -class UpdateManager: - """Manages update checking and execution for KiLM.""" + def __init__( + self, + current_version: str, + *, + method: InstallMethod | None = None, + version_checker: LatestVersionSource | None = None, + ) -> None: + """Prepare update operations for the running installation. - def __init__(self, current_version: str): - self.version_checker = PyPIVersionChecker("kilm") + Args: + current_version: The installed KiLM version. + method: The installation method; detected when omitted. + version_checker: The PyPI client; a cached kilm checker if omitted. + """ self.current_version = current_version - self.installation_method = detect_installation_method() - - def check_latest_version(self) -> str | None: - """Check for latest version available on PyPI.""" - return self.version_checker.check_latest_version() - - def is_newer_version_available(self, latest_version: str) -> bool: - """Compare versions to determine if update is available.""" - try: - current_ver = Version(self.current_version) - latest_ver = Version(latest_version) - return latest_ver > current_ver - except (InvalidVersion, AttributeError): - return False - - def get_update_instruction(self) -> str: - """Get update instruction for the detected installation method.""" - instructions = { - "pipx": "pipx upgrade kilm", - "pip": "pip install --upgrade kilm", - "pip-venv": "pip install --upgrade kilm", - "uv": "uv tool upgrade kilm", - "conda": "Conda package not yet available (planned for future)", - "homebrew": "Homebrew package not yet available (planned for future)", - } - return instructions.get(self.installation_method, "Check your package manager") + self.method: InstallMethod = method or detect_installation_method() + self.version_checker: LatestVersionSource = ( + version_checker or PyPIVersionChecker("kilm", current_version) + ) + @property def can_auto_update(self) -> bool: - """Check if automatic update is possible for this installation method.""" - return self.installation_method in ["pipx", "pip", "pip-venv", "uv"] - - def perform_update(self) -> tuple[bool, str]: - """ - Execute update using detected installation method. - Returns: (success: bool, message: str) - """ - if not self.can_auto_update(): - instruction = self.get_update_instruction() - return False, f"Manual update required. Run: {instruction}" + """Whether KiLM can run the installer for this installation method.""" + return upgrade_command(self.method) is not None - update_functions = { - "pipx": update_via_pipx, - "pip": update_via_pip, - "pip-venv": update_via_pip, - "uv": update_via_uv, - } - - update_func = update_functions.get(self.installation_method) - if update_func: - try: - success = update_func() - if success: - return True, "KiLM updated successfully!" - else: - instruction = self.get_update_instruction() - return False, f"Auto-update failed. Try manually: {instruction}" - except Exception as e: - return False, f"Update error: {str(e)}" - else: - instruction = self.get_update_instruction() - return False, f"Unsupported installation method. Run: {instruction}" + def update_instruction(self, target_version: str | None = None) -> str: + """Return the command, or advice, for updating this installation by hand.""" + return update_instruction(self.method, target_version) + def check_for_updates(self, *, use_cache: bool = True) -> UpdateStatus: + """Compare the installed version with the latest release on PyPI. -class UpdateService: - """Service wrapper for KiLM update functionality.""" - - def __init__(self, current_version: str): - self.manager = UpdateManager(current_version) - self.current_version = current_version - - def check_for_updates(self, use_cache: bool = True) -> dict[str, Any]: + With `use_cache` a version check from the last day is reused. Never + raises for network problems; `latest_version` is then None. """ - Check for available updates. - - Returns: - Dict containing update status information: - - has_update: whether an update is available - - current_version: current installed version - - latest_version: latest available version - - method: installation method identifier - - supports_auto_update: whether automatic update is supported - """ - latest_version = ( - self.manager.check_latest_version() - if use_cache - else self.manager.version_checker.check_latest_version() - ) - has_update = False - - if latest_version: - has_update = self.manager.is_newer_version_available(latest_version) - + latest_version = self.version_checker.check_latest_version(use_cache=use_cache) return { - "has_update": has_update, + "has_update": latest_version is not None + and is_newer_version(latest_version, self.current_version), "current_version": self.current_version, "latest_version": latest_version, - "method": self.manager.installation_method, - "supports_auto_update": self.manager.can_auto_update(), + "method": self.method, + "supports_auto_update": self.can_auto_update, } def show_update_notification( - self, quiet: bool = False, force_check: bool = False + self, *, quiet: bool = False, force_check: bool = False ) -> bool: - """ - Show update notification if updates are available. + """Print a panel when a newer release exists; return whether one does. Args: - quiet: Only show notification if update is available - force_check: Skip cache and force fresh check - - Returns: - bool: True if update is available + quiet: Print nothing when KiLM is up to date. + force_check: Ask PyPI even when a recent answer is cached. """ - from rich.console import Console - from rich.panel import Panel - - console = Console() - update_info = self.check_for_updates(use_cache=not force_check) - - if not update_info["has_update"]: + console = get_console() + status = self.check_for_updates(use_cache=not force_check) + if not status["has_update"]: if not quiet: - console.print("[green]✓[/green] You are using the latest version!") + console.print("[green]You are using the latest version.[/green]") return False - # Create update notification - current = update_info["current_version"] - latest = update_info["latest_version"] - method = update_info["method"] - can_auto_update = update_info["supports_auto_update"] - - message_lines = [ + current = status["current_version"] + latest = status["latest_version"] + lines = [ "[yellow]Update available![/yellow]", f"Current: [blue]{current}[/blue] → Latest: [green]{latest}[/green]", "", ] - - if can_auto_update: - message_lines.extend( - [ - "Run [bold cyan]kilm update[/bold cyan] to update automatically", - f"Installation method: [dim]{method}[/dim]", - ] - ) + if status["supports_auto_update"]: + lines += [ + ( + "Run [bold cyan]kilm update perform[/bold cyan] " + "to update automatically" + ), + f"Installation method: [dim]{self.method}[/dim]", + ] else: - update_cmd = self.manager.get_update_instruction() - message_lines.extend( - [ - f"Installation method: [bold]{method}[/bold] (manual update required)", - f"Run: [bold cyan]{update_cmd}[/bold cyan]", - ] - ) - - message = "\n".join(message_lines) - + instruction = self.update_instruction() + method = f"[bold]{self.method}[/bold]" + lines += [ + f"Installation method: {method} (manual update required)", + f"Update manually: [bold cyan]{instruction}[/bold cyan]", + ] console.print( Panel( - message, + "\n".join(lines), title="[bold blue]KiLM Update Available[/bold blue]", border_style="blue", ) ) - return True - def is_update_available(self, latest_version: str) -> bool: - """Check if an update is available.""" - return self.manager.is_newer_version_available(latest_version) - - def get_installation_method(self) -> str: - """Get detected installation method.""" - return self.manager.installation_method - - def get_update_instructions(self) -> str: - """Get update instructions for current installation method.""" - return self.manager.get_update_instruction() - - def can_auto_update(self) -> bool: - """Check if automatic updates are supported.""" - return self.manager.can_auto_update() - - def perform_update( - self, - target_version: str | None = None, - force: bool = False, - dry_run: bool = False, - ) -> tuple[bool, str]: - """ - Perform update installation. - - Args: - target_version: Specific version to install - force: Force update even if already up to date - dry_run: Show what would be done without doing it + def perform_update(self, target_version: str | None = None) -> UpdateResult: + """Run the installer for the latest release, or exactly `target_version`. - Returns: - tuple[bool, str]: (success, message) + Installs unconditionally: deciding whether an update is needed is the + caller's job. A target version may be older than the installed one. """ - if dry_run: - return self._show_dry_run_info(target_version) - - # Check if update is needed (unless forced) - if not force: - update_info = self.check_for_updates() - if not update_info["has_update"]: - return True, "Already using the latest version!" - - # Perform the update - return self.manager.perform_update() - - def _show_dry_run_info(self, target_version: str | None = None) -> tuple[bool, str]: - """Show what would happen during an update.""" - from rich.console import Console - from rich.panel import Panel - - console = Console() - method = self.manager.installation_method - can_auto_update = self.manager.can_auto_update() - package_spec = f"kilm=={target_version}" if target_version else "kilm" - update_cmd = self.manager.get_update_instruction() - - console.print( - Panel( - f"""[bold]Update Plan (Dry Run)[/bold] - -Installation method: [cyan]{method}[/cyan] -Target package: [green]{package_spec}[/green] -Auto-update supported: [{"green" if can_auto_update else "red"}]{can_auto_update}[/] - -Command that would be executed: -[bold cyan]{update_cmd}[/bold cyan]""", - title="[bold yellow]Dry Run[/bold yellow]", - border_style="yellow", + command = upgrade_command(self.method, target_version) + if command is None: + return UpdateResult( + success=False, + message=f"Manual update required: {self.update_instruction()}", ) + error = run_upgrade(command) + if error is None: + return UpdateResult(success=True, message="KiLM updated successfully!") + instruction = self.update_instruction(target_version) + return UpdateResult( + success=False, message=f"{error}\nTry manually: {instruction}" ) - - return can_auto_update, f"Dry run completed - would execute: {update_cmd}" diff --git a/kicad_lib_manager/services/user_paths.py b/kicad_lib_manager/services/user_paths.py new file mode 100644 index 0000000..ec57580 --- /dev/null +++ b/kicad_lib_manager/services/user_paths.py @@ -0,0 +1,19 @@ +"""Where KiLM keeps its per-user configuration and cache files.""" + +from pathlib import Path +from typing import Final + +from kicad_lib_manager.utils.constants import CONFIG_DIR_NAME, CONFIG_FILE_NAME + +CACHE_DIR_NAME: Final = "kilm" +"""Directory under ~/.cache for version checks and update-check timestamps.""" + + +def user_config_file() -> Path: + """Return the KiLM configuration file of the current user.""" + return Path.home() / ".config" / CONFIG_DIR_NAME / CONFIG_FILE_NAME + + +def user_cache_dir() -> Path: + """Return the KiLM cache directory of the current user.""" + return Path.home() / ".cache" / CACHE_DIR_NAME diff --git a/kicad_lib_manager/utils/__init__.py b/kicad_lib_manager/utils/__init__.py index c6a00df..528d2d8 100644 --- a/kicad_lib_manager/utils/__init__.py +++ b/kicad_lib_manager/utils/__init__.py @@ -1,7 +1,5 @@ -""" -Utility functions for KiCad Library Manager -""" +"""Utility functions for KiCad Library Manager.""" -from .banner import show_banner +from kicad_lib_manager.utils.banner import show_banner __all__ = ["show_banner"] diff --git a/kicad_lib_manager/utils/backup.py b/kicad_lib_manager/utils/backup.py index 71b79b0..8b3b4dd 100644 --- a/kicad_lib_manager/utils/backup.py +++ b/kicad_lib_manager/utils/backup.py @@ -1,55 +1,72 @@ -""" -Utilities for backup management +"""Timestamped backups of files that KiLM is about to change. + +A backup of ```` is the sibling ``.backup.``, where the +stamp is the local time with microseconds. Stamps have a fixed width, so name +order is time order, and pruning treats it as creation order. That holds +while the local clock only moves forward: a clock set back, or the repeated +hour when daylight saving time ends, can make a newer backup sort as older. +Backups from releases that stamped whole seconds sort correctly among them. """ import shutil -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path +from kicad_lib_manager.utils.constants import DEFAULT_MAX_BACKUPS + +_STAMP_FORMAT = "%Y%m%d_%H%M%S_%f" +_BACKUP_INFIX = ".backup." -def create_backup(file_path: Path, max_backups: int = 5) -> Path: - """ - Create a timestamped backup of a file and maintain a limited history + +def create_backup(file_path: Path, max_backups: int = DEFAULT_MAX_BACKUPS) -> Path: + """Copy file_path to a new backup and keep only its newest max_backups backups. + + The backup keeps the file's metadata (shutil.copy2). It never replaces an + earlier backup, even one taken at the same clock reading. Args: - file_path: Path to the file to back up - max_backups: Maximum number of backups to keep + file_path: File to back up. + max_backups: Number of backups of the file to keep, this one included. Returns: - Path to the created backup + Path of the new backup. Raises: - FileNotFoundError: If the file to back up doesn't exist + FileNotFoundError: file_path does not exist. """ if not file_path.exists(): - raise FileNotFoundError(f"File not found: {file_path}") + msg = f"File not found: {file_path}" + raise FileNotFoundError(msg) - # Create timestamp for the backup - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - backup_path = file_path.with_suffix(f"{file_path.suffix}.backup.{timestamp}") - - # Create the backup + backup_path = unique_backup_path(file_path) shutil.copy2(file_path, backup_path) - - # Clean up old backups manage_backup_history(file_path, max_backups) - return backup_path -def manage_backup_history(file_path: Path, max_backups: int = 5) -> None: - """ - Maintain a limited history of backups, removing the oldest ones +def unique_backup_path(file_path: Path) -> Path: + """Return an unused backup path for file_path, named after the current time. - Args: - file_path: Path to the original file - max_backups: Maximum number of backups to keep + When the name is taken (clocks can repeat a reading), the stamp moves + forward a microsecond at a time, so the new name still sorts after the + one it collided with. """ - # Find all backups for this file - pattern = f"{file_path.name}.backup.*" - backups = sorted(file_path.parent.glob(pattern)) - - # Remove oldest backups if we have too many - if len(backups) > max_backups: - for old_backup in backups[:-max_backups]: - old_backup.unlink() + stamp = datetime.now().astimezone() + while (path := _backup_path(file_path, stamp)).exists(): + stamp += timedelta(microseconds=1) + return path + + +def manage_backup_history( + file_path: Path, max_backups: int = DEFAULT_MAX_BACKUPS +) -> None: + """Delete file_path's backups beyond the max_backups that sort last by name.""" + backups = sorted(file_path.parent.glob(f"{file_path.name}{_BACKUP_INFIX}*")) + for old_backup in backups[:-max_backups]: + old_backup.unlink() + + +def _backup_path(file_path: Path, stamp: datetime) -> Path: + return file_path.with_name( + f"{file_path.name}{_BACKUP_INFIX}{stamp.strftime(_STAMP_FORMAT)}" + ) diff --git a/kicad_lib_manager/utils/banner.py b/kicad_lib_manager/utils/banner.py index 2007bae..ca3e912 100644 --- a/kicad_lib_manager/utils/banner.py +++ b/kicad_lib_manager/utils/banner.py @@ -1,70 +1,45 @@ -""" -Banner utilities for KiCad Library Manager -""" +"""The KiLM ASCII-art banner.""" from typing import Literal from rich.console import Console +_BANNER_LINES = ( + "██╗ ██╗██╗██╗ ███╗ ███╗", + "██║ ██╔╝██║██║ ████╗ ████║", + "█████╔╝ ██║██║ ██╔████╔██║", + "██╔═██╗ ██║██║ ██║╚██╔╝██║", + "██║ ██╗██║███████╗██║ ╚═╝ ██║", + "╚═╝ ╚═╝╚═╝╚══════╝╚═╝ ╚═╝", +) +# One color per line for the solid blocks that form the letters, a gradient. +_LETTER_COLORS = ( + "bright_blue", + "bright_blue", + "bright_cyan", + "bright_cyan", + "bright_green", + "bright_white", +) +# Color of the box-drawing shadow around the letters. +_SHADOW_COLOR = "white" +_LETTER_CHAR = "█" + def show_banner( console: Console, justify: Literal["center", "left", "right"] = "center" ) -> None: - """ - Display the KiLM banner with colorful ASCII art. - - Front characters (KILM text) and back characters (box drawing) use different colors - to create a layered visual effect. + """Print the KiLM banner, with the letters and their shadow in different colors. Args: - console: Rich console instance to print the banner to + console: Rich console to print the banner to. + justify: Horizontal alignment of the banner. """ - banner_lines = [ - "██╗ ██╗██╗██╗ ███╗ ███╗", - "██║ ██╔╝██║██║ ████╗ ████║", - "█████╔╝ ██║██║ ██╔████╔██║", - "██╔═██╗ ██║██║ ██║╚██╔╝██║", - "██║ ██╗██║███████╗██║ ╚═╝ ██║", - "╚═╝ ╚═╝╚═╝╚══════╝╚═╝ ╚═╝", - ] - - # Colors for front characters (KILM text) - gradient effect - front_colors = [ - "bright_blue", - "bright_blue", - "bright_cyan", - "bright_cyan", - "bright_green", - "bright_white", - ] - - # Colors for back characters (box drawing) - darker, more subtle - back_colors = [ - "white", - "white", - "white", - "white", - "white", - "white", - ] - - # Print each line with different colors for front and back characters - for line, front_color, back_color in zip( - banner_lines, front_colors, back_colors, strict=True - ): - # Split the line into front characters (KILM) and back characters (box drawing) - # The KILM text is represented by the block characters ██╗, ██║, etc. - # The back characters are the decorative box drawing elements - - # Only the solid block characters ██ are front characters - # All other characters (╗╔╝║═╚ and spaces) are back characters - colored_line = "" - for char in line: - if char == "█": - # Only the solid block character is the front character - colored_line += f"[bold {front_color}]{char}[/]" - else: - # All other characters are back characters - colored_line += f"[{back_color}]{char}[/]" - + for line, letter_color in zip(_BANNER_LINES, _LETTER_COLORS, strict=True): + colored_line = "".join( + f"[bold {letter_color}]{char}[/]" + if char == _LETTER_CHAR + else f"[{_SHADOW_COLOR}]{char}[/]" + for char in line + ) console.print(colored_line, justify=justify) diff --git a/kicad_lib_manager/utils/console.py b/kicad_lib_manager/utils/console.py new file mode 100644 index 0000000..9d5297a --- /dev/null +++ b/kicad_lib_manager/utils/console.py @@ -0,0 +1,21 @@ +"""Rich consoles for CLI output, created at the moment output is written. + +A Rich ``Console`` decides its color system and terminal mode when it is +constructed. A console stored at import time therefore keeps the decision made +for whatever stdout looked like then: imported from a terminal, it keeps +writing ANSI codes after output is redirected, for example into Click's test +runner. Code that prints calls ``get_console`` instead of holding a console. +""" + +from rich.console import Console + + +def get_console(*, stderr: bool = False) -> Console: + """Return a console for the current stdout, or stderr when ``stderr`` is set. + + Every call builds a new console, so redirection and environment changes such + as ``NO_COLOR`` made after import take effect. Keep the result in a local + variable when one operation needs a single console instance, such as a live + status display. + """ + return Console(stderr=stderr) diff --git a/kicad_lib_manager/utils/constants.py b/kicad_lib_manager/utils/constants.py index 6184746..3f831c1 100644 --- a/kicad_lib_manager/utils/constants.py +++ b/kicad_lib_manager/utils/constants.py @@ -1,11 +1,22 @@ -""" -Constants used throughout KiCad Library Manager. -""" +"""Constants used throughout KiCad Library Manager.""" # Metadata filenames GITHUB_METADATA_FILE = "kilm.yaml" CLOUD_METADATA_FILE = ".kilm_metadata" +# KiCad's own configuration file and library tables in its configuration directory +KICAD_COMMON_FILE = "kicad_common.json" +SYMBOL_TABLE_FILE = "sym-lib-table" +FOOTPRINT_TABLE_FILE = "fp-lib-table" + +# KiCad path variables naming the main symbol and footprint library directory +# and the main 3D model library directory +SYMBOL_DIR_VAR = "KICAD_USER_LIB" +MODELS_DIR_VAR = "KICAD_3D_LIB" + +# Suffixes of the files counted as 3D models in a model library +MODEL_EXTENSIONS = (".step", ".stp", ".wrl", ".wings") + # Configuration paths and directories CONFIG_DIR_NAME = "kicad-lib-manager" CONFIG_FILE_NAME = "config.yaml" @@ -16,6 +27,9 @@ TEMPLATE_CONTENT_DIR = "template" HOOKS_DIR = "hooks" +# Library metadata +# Longest environment variable name KiLM generates; some systems limit length. +MAX_ENV_VAR_NAME_LENGTH = 50 + # Default configuration values DEFAULT_MAX_BACKUPS = 5 -DEFAULT_LIBRARIES: list = [] diff --git a/kicad_lib_manager/utils/env_vars.py b/kicad_lib_manager/utils/env_vars.py index 9832de9..231400e 100644 --- a/kicad_lib_manager/utils/env_vars.py +++ b/kicad_lib_manager/utils/env_vars.py @@ -1,5 +1,9 @@ -""" -Environment variable handling utilities +"""Library paths from the user's environment, and KiCad's kicad_common.json. + +find_library_variable looks a library path up the way a user would set +it. The update functions change the environment variables and pinned +libraries in kicad_common.json, backing the file up first and replacing it +atomically. """ import json @@ -7,313 +11,279 @@ import re import shutil import subprocess +from collections.abc import Callable, Mapping, Sequence from pathlib import Path +from typing import NamedTuple, cast + +from kicad_lib_manager.services.config_service import Config +from kicad_lib_manager.utils.constants import ( + CLOUD_METADATA_FILE, + DEFAULT_MAX_BACKUPS, + GITHUB_METADATA_FILE, + KICAD_COMMON_FILE, + MODELS_DIR_VAR, + SYMBOL_DIR_VAR, +) +from kicad_lib_manager.utils.kicad_common import ( + PINNED_FOOTPRINTS_KEY, + PINNED_SYMBOLS_KEY, + JsonObject, + replace_json_object, +) + + +class KiCadConfigError(ValueError): + """kicad_common.json is not valid JSON or not shaped as KiCad writes it.""" + + +class _LibraryVariable(NamedTuple): + """How KiLM derives a main library variable without the user setting it.""" + + configured_path: Callable[[Config], str | None] + """Reads the current library of this kind from the KiLM configuration.""" + marker_file: str + """The metadata file that makes a directory a library of this kind.""" + + +_LIBRARY_VARIABLES = { + SYMBOL_DIR_VAR: _LibraryVariable( + lambda config: config.get_symbol_library_path(), GITHUB_METADATA_FILE + ), + MODELS_DIR_VAR: _LibraryVariable( + lambda config: config.get_3d_library_path(), CLOUD_METADATA_FILE + ), +} +_SHELL_FILES = (".bashrc", ".bash_profile", ".profile", ".zshrc") + + +def find_library_variable(var_name: str) -> str | None: + """Return the value of var_name from the first source that defines it. + + The sources, in order: the KiLM configuration (current library paths, for + KICAD_USER_LIB and KICAD_3D_LIB), a library metadata file in the current + directory (the directory then is the library), the process environment, + fish universal variables, and export lines in ~/.bashrc, ~/.bash_profile, + ~/.profile, and ~/.zshrc. A source that fails to read is skipped. -# Import Config here, but only use it when needed to avoid circular imports -try: - from ..services.config_service import Config -except ImportError: - Config = None - -# Try to import metadata utilities without causing circular imports -try: - from ..utils.metadata import ( - CLOUD_METADATA_FILE, - GITHUB_METADATA_FILE, - read_cloud_metadata, - read_github_metadata, + Returns: + The value, or None when no source defines it. + """ + lookups: tuple[Callable[[str], str | None], ...] = ( + _from_config, + _from_current_directory, + os.environ.get, + _from_fish, + _from_shell_files, ) -except ImportError: - read_github_metadata = None - read_cloud_metadata = None - GITHUB_METADATA_FILE = "kilm.yaml" - CLOUD_METADATA_FILE = ".kilm_metadata" + for lookup in lookups: + value = lookup(var_name) + if value is not None: + return value + return None -def find_environment_variables(var_name: str) -> str | None: - """ - Find environment variables from various sources in the following order: - 1. Config file - 2. Metadata files in current directory - 3. Environment variables - 4. Shell config files +def _from_config(var_name: str) -> str | None: + variable = _LIBRARY_VARIABLES.get(var_name) + if variable is None: + return None + try: + path = variable.configured_path(Config()) + except (OSError, LookupError, TypeError, ValueError): + return None + return path or None - Args: - var_name: The name of the environment variable to find - Returns: - The value of the environment variable, or None if not found - """ - # First check configuration file if it's a known variable - if Config is not None: - try: - config = Config() - - # Check for KiCad library variables in config - if var_name == "KICAD_USER_LIB": - lib_path = config.get_symbol_library_path() - if lib_path: - return lib_path - - # Check for 3D model variables in config - elif var_name == "KICAD_3D_LIB": - lib_path = config.get_3d_library_path() - if lib_path: - return lib_path - except Exception: - # If there's any error with config, fall back to other methods - pass - - # Check for metadata file in current directory +def _from_current_directory(var_name: str) -> str | None: + variable = _LIBRARY_VARIABLES.get(var_name) + if variable is None: + return None try: current_dir = Path.cwd().resolve() + is_library = (current_dir / variable.marker_file).exists() + except OSError: + return None + return str(current_dir) if is_library else None - # For KiCad library directory, check for GitHub metadata - if var_name == "KICAD_USER_LIB" and read_github_metadata: - metadata_file = current_dir / GITHUB_METADATA_FILE - if metadata_file.exists(): - # Found metadata file in current directory, probably a KiCad library - return str(current_dir) - - # For 3D library directory, check for cloud metadata - elif var_name == "KICAD_3D_LIB" and read_cloud_metadata: - metadata_file = current_dir / CLOUD_METADATA_FILE - if metadata_file.exists(): - # Found metadata file in current directory, probably a 3D model library - return str(current_dir) - except Exception: - # If any error occurs, continue to other methods - pass - - # Check environment directly - if var_name in os.environ: - return os.environ[var_name] - - # Check for fish universal variables - if shutil.which("fish"): - try: - result = subprocess.run( - ["fish", "-c", f"echo ${var_name}"], - capture_output=True, - text=True, - check=False, - ) - if result.stdout.strip(): - return result.stdout.strip() - except Exception: - pass - - # Check common shell config files - home = Path.home() - config_files = [ - home / ".bashrc", - home / ".bash_profile", - home / ".profile", - home / ".zshrc", - ] - pattern = re.compile(rf"^(?:export\s+)?{var_name}=[\'\"]?(.*?)[\'\"]?$") +def _from_fish(var_name: str) -> str | None: + fish = shutil.which("fish") + if fish is None: + return None + try: + result = subprocess.run( # noqa: S603 -- fixed fish command; var_name comes from KiLM code, not user input + [fish, "-c", f"echo ${var_name}"], + capture_output=True, + text=True, + check=False, + ) + except (OSError, subprocess.SubprocessError, ValueError): + return None + return result.stdout.strip() or None - for config_file in config_files: - if not config_file.exists(): - continue +def _from_shell_files(var_name: str) -> str | None: + pattern = re.compile(rf"^(?:export\s+)?{re.escape(var_name)}=[\'\"]?(.*?)[\'\"]?$") + home = Path.home() + for name in _SHELL_FILES: try: - with Path(config_file).open() as f: - for line in f: - match = pattern.search(line.strip()) - if match: - return match.group(1) - except Exception: - pass - + text = (home / name).read_text(encoding="utf-8", errors="replace") + except OSError: + continue + for line in text.splitlines(): + match = pattern.search(line.strip()) + if match: + return str(match.group(1)) return None def expand_user_path(path: str) -> str: - """ - Expand a path that might start with ~ to an absolute path - - Args: - path: The path to expand - - Returns: - The expanded path - """ + """Return path with a leading ~ expanded to the home directory.""" if path.startswith("~"): return str(Path(path).expanduser()) return path def update_kicad_env_vars( - kicad_config: Path, env_vars: dict, dry_run: bool = False, max_backups: int = 5 + kicad_config: Path, + env_vars: Mapping[str, str | None], + *, + dry_run: bool = False, + max_backups: int = DEFAULT_MAX_BACKUPS, ) -> bool: - """ - Update environment variables in KiCad's common configuration file + """Set or remove environment variables in kicad_common.json. + + A None value removes the variable; blank values are ignored. Paths are + stored with forward slashes. The file is backed up and rewritten only + when something changes, and never in a dry run. Args: - kicad_config: Path to the KiCad configuration directory - env_vars: Dictionary of environment variables to set - dry_run: If True, don't make any changes - max_backups: Maximum number of backups to keep + kicad_config: KiCad's configuration directory. + env_vars: Variable name to value (None removes it). + dry_run: Report whether anything would change without writing. + max_backups: Number of backups of kicad_common.json to keep. Returns: - True if changes were made, False otherwise + Whether the variables differ (or would differ) from the file. Raises: - FileNotFoundError: If the KiCad common configuration file is not found + FileNotFoundError: kicad_common.json does not exist. + KiCadConfigError: kicad_common.json is not valid JSON of the expected shape. """ - from .backup import create_backup - - # Validate input - if not env_vars or not isinstance(env_vars, dict): + requested = {k: v for k, v in env_vars.items() if v is None or v.strip()} + if not requested: return False - # Filter out empty strings from env_vars, but keep None values - valid_env_vars = {k: v for k, v in env_vars.items() if v is None or str(v).strip()} - - # If no environment variables to process, return False - if not valid_env_vars: - return False - - kicad_common = kicad_config / "kicad_common.json" - - if not kicad_common.exists(): - raise FileNotFoundError( - f"KiCad common configuration file not found at {kicad_common}" - ) - - try: - with Path(kicad_common).open() as f: - config = json.load(f) - except json.JSONDecodeError as e: - raise ValueError(f"Invalid JSON format in {kicad_common}") from e - - # Check if environment section exists - if "environment" not in config: - config["environment"] = {"vars": {}} - elif "vars" not in config["environment"]: - config["environment"]["vars"] = {} - - # Check if changes are needed - changes_needed = False - current_vars = config["environment"]["vars"] - - for key, value in valid_env_vars.items(): - # Handle None values by removing the variable + kicad_common, config = _load_kicad_common(kicad_config) + environment = _object_entry(config, "environment", kicad_common) + current = _object_entry(environment, "vars", kicad_common) + updated = dict(current) + for key, value in requested.items(): if value is None: - if key in current_vars: - changes_needed = True - if not dry_run: - del current_vars[key] - continue - - # Ensure path uses forward slashes - normalized_value = value.replace("\\", "/") + updated.pop(key, None) + else: + updated[key] = value.replace("\\", "/") - if key not in current_vars or current_vars[key] != normalized_value: - changes_needed = True - if not dry_run: - current_vars[key] = normalized_value - - # Write changes if needed - if changes_needed and not dry_run: - # Create backup before making changes - create_backup(kicad_common, max_backups) - - with Path(kicad_common).open("w") as f: - json.dump(config, f, indent=2) - - return changes_needed + if updated == current: + return False + if not dry_run: + environment["vars"] = updated + replace_json_object(kicad_common, config, max_backups=max_backups) + return True def update_pinned_libraries( kicad_config: Path, - symbol_libs: list[str] | None = None, - footprint_libs: list[str] | None = None, + *, + symbol_libs: Sequence[str] | None = None, + footprint_libs: Sequence[str] | None = None, dry_run: bool = False, - max_backups: int = 5, + max_backups: int = DEFAULT_MAX_BACKUPS, ) -> bool: - """ - Update pinned libraries in KiCad's common configuration file + """Pin libraries in kicad_common.json, keeping those already pinned. + + The file is backed up and rewritten only when a library is newly pinned, + and never in a dry run. Args: - kicad_config: Path to the KiCad configuration directory - symbol_libs: List of symbol libraries to pin - footprint_libs: List of footprint libraries to pin - dry_run: If True, don't make any changes - max_backups: Maximum number of backups to keep + kicad_config: KiCad's configuration directory. + symbol_libs: Symbol library names to pin. + footprint_libs: Footprint library names to pin. + dry_run: Report whether anything would change without writing. + max_backups: Number of backups of kicad_common.json to keep. Returns: - True if changes were made, False otherwise + Whether a library is (or would be) newly pinned. Raises: - FileNotFoundError: If the KiCad common configuration file is not found + FileNotFoundError: kicad_common.json does not exist. + KiCadConfigError: kicad_common.json is not valid JSON of the expected shape. """ - from .backup import create_backup - - # Default empty lists if None - symbol_libs = symbol_libs or [] - footprint_libs = footprint_libs or [] - - # Skip if both are empty - if not symbol_libs and not footprint_libs: + requested = { + PINNED_SYMBOLS_KEY: () if symbol_libs is None else symbol_libs, + PINNED_FOOTPRINTS_KEY: () if footprint_libs is None else footprint_libs, + } + if not any(requested.values()): return False - kicad_common = kicad_config / "kicad_common.json" - - if not kicad_common.exists(): - raise FileNotFoundError( - f"KiCad common configuration file not found at {kicad_common}" - ) - - try: - with Path(kicad_common).open() as f: - config = json.load(f) - except json.JSONDecodeError as e: - raise ValueError(f"Invalid JSON format in {kicad_common}") from e - - # Ensure session section exists - if "session" not in config: + kicad_common, config = _load_kicad_common(kicad_config) + if config.get("session") is None: config["session"] = { - "pinned_symbol_libs": [], - "pinned_fp_libs": [], + PINNED_SYMBOLS_KEY: [], + PINNED_FOOTPRINTS_KEY: [], "remember_open_files": False, } - - # Ensure the pinned libraries sections exist - if "pinned_symbol_libs" not in config["session"]: - config["session"]["pinned_symbol_libs"] = [] - if "pinned_fp_libs" not in config["session"]: - config["session"]["pinned_fp_libs"] = [] - - # Convert lists to keep track of original state - current_pinned_symbols = list(config["session"]["pinned_symbol_libs"]) - current_pinned_footprints = list(config["session"]["pinned_fp_libs"]) - - # Check for changes to symbol libraries - changes_needed = False - - # Add new symbol libraries that aren't already pinned - for lib in symbol_libs: - if lib not in current_pinned_symbols: - changes_needed = True - if not dry_run: - config["session"]["pinned_symbol_libs"].append(lib) - - # Add new footprint libraries that aren't already pinned - for lib in footprint_libs: - if lib not in current_pinned_footprints: - changes_needed = True - if not dry_run: - config["session"]["pinned_fp_libs"].append(lib) - - # Write changes if needed - if changes_needed and not dry_run: - # Create backup before making changes - create_backup(kicad_common, max_backups) - - with Path(kicad_common).open("w") as f: - json.dump(config, f, indent=2) - - return changes_needed + session = _object_entry(config, "session", kicad_common) + changed = False + for key, libs in requested.items(): + pinned = _list_entry(session, key, kicad_common) + new = [lib for lib in dict.fromkeys(libs) if lib not in pinned] + changed = changed or bool(new) + if not dry_run: + pinned.extend(new) + + if changed and not dry_run: + replace_json_object(kicad_common, config, max_backups=max_backups) + return changed + + +def _load_kicad_common(kicad_config: Path) -> tuple[Path, JsonObject]: + """Path and parsed content of kicad_common.json in kicad_config.""" + kicad_common = kicad_config / KICAD_COMMON_FILE + if not kicad_common.exists(): + msg = f"KiCad common configuration file not found at {kicad_common}" + raise FileNotFoundError(msg) + try: + document: object = json.loads(kicad_common.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + msg = f"Invalid JSON format in {kicad_common}" + raise KiCadConfigError(msg) from e + if not isinstance(document, dict): + msg = f"Invalid KiCad configuration in {kicad_common}: expected an object" + raise KiCadConfigError(msg) + return kicad_common, cast("JsonObject", document) + + +def _object_entry(parent: JsonObject, key: str, kicad_common: Path) -> JsonObject: + """parent[key] as a JSON object; an absent or null entry becomes {}.""" + value = parent.get(key) + if value is None: + created: JsonObject = {} + parent[key] = created + return created + if not isinstance(value, dict): + msg = f"Invalid {key!r} in {kicad_common}: expected an object" + raise KiCadConfigError(msg) + return cast("JsonObject", value) + + +def _list_entry(parent: JsonObject, key: str, kicad_common: Path) -> list[object]: + """parent[key] as a JSON array; an absent or null entry becomes [].""" + value = parent.get(key) + if value is None: + created: list[object] = [] + parent[key] = created + return created + if not isinstance(value, list): + msg = f"Invalid {key!r} in {kicad_common}: expected an array" + raise KiCadConfigError(msg) + return cast("list[object]", value) diff --git a/kicad_lib_manager/utils/file_ops.py b/kicad_lib_manager/utils/file_ops.py index 8e312cc..14ccd53 100644 --- a/kicad_lib_manager/utils/file_ops.py +++ b/kicad_lib_manager/utils/file_ops.py @@ -1,68 +1,119 @@ -""" -File operation utilities -""" +"""Reading and writing KiCad library tables and other files KiLM changes.""" import os import re import shutil import uuid +from collections.abc import Sequence from pathlib import Path - - -def read_file_with_encoding(file_path: Path, encodings: list[str] | None = None) -> str: - """ - Read a file trying multiple encodings until successful - - Args: - file_path: Path to the file to read - encodings: List of encodings to try. Defaults to ['utf-8', 'utf-16', 'iso-8859-1'] - - Returns: - The file contents as a string +from typing import Literal, NamedTuple, TypedDict + +from kicad_lib_manager.utils.constants import FOOTPRINT_TABLE_FILE, SYMBOL_TABLE_FILE + +_DEFAULT_ENCODINGS = ("utf-8", "utf-16", "iso-8859-1") + +# One (lib ...) entry per line, as KiCad writes library tables. +_LIB_ENTRY_RE = re.compile(r"^\s*\(lib\s+(.*?)\)\s*$", re.MULTILINE) +# A token of an entry: a quoted string (group 1, escapes undecoded) or else a +# parenthesis or an unquoted atom (group 2). +_TOKEN_RE = re.compile(r'"((?:[^"\\]|\\.)*)"|([()]|[^\s()"]+)', re.DOTALL) +# An escape in a quoted string: hex bytes, octal bytes, or any other character. +_ESCAPE_RE = re.compile(rb"\\(?:x[0-9A-Fa-f]{1,2}|[0-7]{1,3}|.)", re.DOTALL) +_OCTAL_DIGITS = frozenset(b"01234567") +_NAMED_ESCAPES = { + b'"': b'"', + b"\\": b"\\", + b"a": b"\a", + b"b": b"\b", + b"f": b"\f", + b"n": b"\n", + b"r": b"\r", + b"t": b"\t", + b"v": b"\v", + b"x": b"x", +} +_Property = Literal["name", "uri", "type", "description"] +# Entry properties by keyword; "url" is accepted for "uri" too. +_PROPERTY_KEYWORDS: dict[str, _Property] = { + "name": "name", + "uri": "uri", + "url": "uri", + "type": "type", + "descr": "description", +} +_OPTIONAL_PROPERTIES: tuple[Literal["uri", "type", "description"], ...] = ( + "uri", + "type", + "description", +) + + +class _LibraryTableProperties(TypedDict, total=False): + """Optional properties of a library table entry.""" + + uri: str + type: str + description: str + + +class LibraryTableEntry(_LibraryTableProperties): + """A library in sym-lib-table or fp-lib-table; only name is always present.""" + + name: str + + +def read_file_with_encoding( + file_path: Path, encodings: Sequence[str] = _DEFAULT_ENCODINGS +) -> str: + """Return the text of file_path in the first of encodings that decodes it. + + Line endings are normalized to LF as in text mode. An empty encodings + sequence returns "". Raises: - UnicodeDecodeError: If none of the encodings work + UnicodeDecodeError: No encoding decodes the file; the error is the + last encoding's. """ - if encodings is None: - encodings = ["utf-8", "utf-16", "iso-8859-1"] - - last_error = None + data = file_path.read_bytes() + error: UnicodeDecodeError | None = None for encoding in encodings: - try: - with Path(file_path).open(encoding=encoding) as f: - return f.read() - except UnicodeDecodeError as e: - last_error = e - continue - - if last_error: - raise last_error + text = _decode(data, encoding) + if isinstance(text, str): + return text.replace("\r\n", "\n").replace("\r", "\n") + error = text + if error is not None: + raise error return "" +def _decode(data: bytes, encoding: str) -> str | UnicodeDecodeError: + """Return data decoded with encoding, or the decoding error.""" + try: + return data.decode(encoding) + except UnicodeDecodeError as e: + return e + + def write_file_with_encoding( file_path: Path, content: str, encoding: str = "utf-8" ) -> None: - """ - Write content to a file with the specified encoding + """Replace file_path with content in the given encoding, atomically. - Args: - file_path: Path to the file to write - content: Content to write - encoding: Encoding to use (defaults to utf-8) + The content is encoded before the file is touched, and write_atomically + replaces the file in one step, so a failure leaves the old file intact. + Line endings are written as given, on every OS. """ - with Path(file_path).open("w", encoding=encoding) as f: - f.write(content) + write_atomically(file_path, content.encode(encoding)) def write_atomically(path: Path, data: bytes) -> None: - """Replace path with data so readers see the old or the new file, never a partial one. + """Replace path with data so readers see the old or new file, never a partial one. The data goes to a hidden sibling first, which keeps the final rename on one filesystem and therefore atomic, and is flushed to disk before the - rename. A symlinked path is followed. The new file takes over an existing file's permission bits; a new - file gets the default ones (0o666 less the umask). On failure the sibling - is removed and path is unchanged. + rename. A symlinked path is followed. The new file takes over an existing + file's permission bits; a new file gets the default ones (0o666 less the + umask). On failure the sibling is removed and path is unchanged. """ path = path.resolve() # write through a symlink instead of replacing it tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex[:8]}.tmp") @@ -82,246 +133,154 @@ def write_atomically(path: Path, data: bytes) -> None: raise -def validate_lib_table(table_path: Path, create_if_missing: bool = True) -> None: - """ - Validate a KiCad library table file +def validate_lib_table( + table_path: Path, + *, + create_if_missing: bool = True, +) -> None: + """Check that table_path is a KiCad library table, creating an empty one if allowed. - Args: - table_path: Path to the library table - create_if_missing: If True, create the table if it doesn't exist + The table kind follows the file name: fp-lib-table is a footprint table, + anything else a symbol table. Raises: - ValueError: If the table format is invalid + ValueError: The table is missing and create_if_missing is False, or + its content is not a table of its kind. """ - # Determine table type from filename - table_type = "fp" if table_path.name == "fp-lib-table" else "sym" - - # Create table if it doesn't exist + table_type = "fp" if table_path.name == FOOTPRINT_TABLE_FILE else "sym" if not table_path.exists(): - if create_if_missing: - write_file_with_encoding( - table_path, f"({table_type}_lib_table\n (version 7)\n)\n" - ) - else: - raise ValueError(f"Library table not found: {table_path}") - - # Read and validate content - content = read_file_with_encoding(table_path) + if not create_if_missing: + msg = f"Library table not found: {table_path}" + raise ValueError(msg) + write_file_with_encoding( + table_path, f"({table_type}_lib_table\n (version 7)\n)\n" + ) - # Check if the content starts with the correct table type and ends with a closing parenthesis + content = read_file_with_encoding(table_path) if not content.startswith( f"({table_type}_lib_table" ) or not content.rstrip().endswith(")"): - raise ValueError(f"Invalid library table format in {table_path}") - - -def add_symbol_lib( - lib_name: str, - lib_path: str, - description: str, - sym_table: Path, - dry_run: bool = False, -) -> bool: - """ - Add a symbol library to the KiCad configuration - - Args: - lib_name: The name of the library - lib_path: The path to the library file - description: A description of the library - sym_table: Path to the symbol library table file - dry_run: If True, don't make any changes - - Returns: - True if the library was added, False if it already exists - """ - content = read_file_with_encoding(sym_table) - - # Check if library already exists - if re.search(rf'\(lib \(name "{re.escape(lib_name)}"\)', content): - return False - - if dry_run: - return True - - # Add the library - lines = content.splitlines() - last_line = lines[-1] - - if last_line.strip() == ")": - lines = lines[:-1] - - # Add the new library entry - entry = f' (lib (name "{lib_name}")(type "KiCad")(uri "{lib_path}")(options "")(descr "{description}"))' - lines.append(entry) - lines.append(")") - - # Write with UTF-8 encoding - write_file_with_encoding(sym_table, "\n".join(lines) + "\n") - - return True - else: - raise ValueError( - "Invalid symbol library table format: missing closing parenthesis" - ) - - -def add_footprint_lib( - lib_name: str, - lib_path: str, - description: str, - fp_table: Path, - dry_run: bool = False, -) -> bool: - """ - Add a footprint library to the KiCad configuration - - Args: - lib_name: The name of the library - lib_path: The path to the library directory - description: A description of the library - fp_table: Path to the footprint library table file - dry_run: If True, don't make any changes - - Returns: - True if the library was added, False if it already exists - """ - content = read_file_with_encoding(fp_table) - - # Check if library already exists - if re.search(rf'\(lib \(name "{re.escape(lib_name)}"\)', content): - return False - - if dry_run: - return True - - # Add the library - lines = content.splitlines() - last_line = lines[-1] - - if last_line.strip() == ")": - lines = lines[:-1] - - # Add the new library entry - entry = f' (lib (name "{lib_name}")(type "KiCad")(uri "{lib_path}")(options "")(descr "{description}"))' - lines.append(entry) - lines.append(")") - - # Write with UTF-8 encoding - write_file_with_encoding(fp_table, "\n".join(lines) + "\n") - - return True - else: - raise ValueError( - "Invalid footprint library table format: missing closing parenthesis" - ) + msg = f"Invalid library table format in {table_path}" + raise ValueError(msg) def list_libraries(kicad_lib_dir: str) -> tuple[list[str], list[str]]: - """ - List all available libraries in the repository + """List the symbol and footprint libraries in a KiLM library directory. - Args: - kicad_lib_dir: The KiCad library directory + Symbol libraries are the symbols/*.kicad_sym files, footprint libraries + the footprints/*.pretty directories; both are returned by name. Returns: - A tuple of (symbol libraries, footprint libraries) + (symbol library names, footprint library names) Raises: - FileNotFoundError: If the KiCad library directory is not found + FileNotFoundError: kicad_lib_dir does not exist. """ kicad_lib_path = Path(kicad_lib_dir) - if not kicad_lib_path.exists(): - raise FileNotFoundError(f"KiCad library directory not found at {kicad_lib_dir}") - - symbols = [] - footprints = [] - - # Find symbol libraries - symbols_dir = kicad_lib_path / "symbols" - if symbols_dir.exists(): - symbols = [f.stem for f in symbols_dir.glob("*.kicad_sym") if f.is_file()] - - # Find footprint libraries - footprints_dir = kicad_lib_path / "footprints" - if footprints_dir.exists(): - footprints = [d.stem for d in footprints_dir.glob("*.pretty") if d.is_dir()] - + msg = f"KiCad library directory not found at {kicad_lib_dir}" + raise FileNotFoundError(msg) + + symbols = [ + f.stem for f in (kicad_lib_path / "symbols").glob("*.kicad_sym") if f.is_file() + ] + footprints = [ + d.stem for d in (kicad_lib_path / "footprints").glob("*.pretty") if d.is_dir() + ] return symbols, footprints -def list_configured_libraries(kicad_config: Path) -> tuple[list[dict], list[dict]]: - """ - List all libraries currently configured in KiCad +def list_configured_libraries( + kicad_config: Path, +) -> tuple[list[LibraryTableEntry], list[LibraryTableEntry]]: + """List the libraries in KiCad's sym-lib-table and fp-lib-table. - Args: - kicad_config: Path to the KiCad configuration directory + A missing table lists no libraries; entries without a name are skipped. + Quoted values are decoded as KiCad reads them, so they equal the values + that KiCad or KiLM wrote. Returns: - A tuple of (symbol libraries, footprint libraries) as lists of dictionaries - containing library details - - Raises: - FileNotFoundError: If library tables are not found + (symbol library entries, footprint library entries) """ - sym_table = kicad_config / "sym-lib-table" - fp_table = kicad_config / "fp-lib-table" - - symbol_libs = [] - footprint_libs = [] - - if sym_table.exists(): - content = read_file_with_encoding(sym_table) + return ( + _read_lib_table(kicad_config / SYMBOL_TABLE_FILE), + _read_lib_table(kicad_config / FOOTPRINT_TABLE_FILE), + ) - # Match each complete (lib ...) entry on its own line - # This is more robust than trying to match up to the first ')' - for match in re.finditer(r"^\s*\(lib\s+(.*?)\)\s*$", content, re.MULTILINE): - entry = match.group(1) - name_match = re.search(r'\(name\s+"([^"]+)"\)', entry) - if not name_match: - continue - lib_info = {"name": name_match.group(1)} - - # Extract other properties (support both uri and url just in case) - uri_match = re.search(r'\((?:uri|url)\s+"([^"]+)"\)', entry) - if uri_match: - lib_info["uri"] = uri_match.group(1) - - type_match = re.search(r'\(type\s+"([^"]+)"\)', entry) - if type_match: - lib_info["type"] = type_match.group(1) - - descr_match = re.search(r'\(descr\s+"([^"]+)"\)', entry) - if descr_match: - lib_info["description"] = descr_match.group(1) - - symbol_libs.append(lib_info) +def _read_lib_table(table_path: Path) -> list[LibraryTableEntry]: + if not table_path.exists(): + return [] + content = read_file_with_encoding(table_path) + entries: list[LibraryTableEntry] = [] + for match in _LIB_ENTRY_RE.finditer(content): + entry = _parse_lib_entry(str(match.group(1))) + if entry is not None: + entries.append(entry) + return entries - if fp_table.exists(): - content = read_file_with_encoding(fp_table) - for match in re.finditer(r"^\s*\(lib\s+(.*?)\)\s*$", content, re.MULTILINE): - entry = match.group(1) +class _Token(NamedTuple): + """A token of a table entry; text is decoded when quoted.""" - name_match = re.search(r'\(name\s+"([^"]+)"\)', entry) - if not name_match: - continue - lib_info = {"name": name_match.group(1)} + text: str + quoted: bool - uri_match = re.search(r'\((?:uri|url)\s+"([^"]+)"\)', entry) - if uri_match: - lib_info["uri"] = uri_match.group(1) - type_match = re.search(r'\(type\s+"([^"]+)"\)', entry) - if type_match: - lib_info["type"] = type_match.group(1) +_OPEN = _Token("(", quoted=False) +_CLOSE = _Token(")", quoted=False) - descr_match = re.search(r'\(descr\s+"([^"]+)"\)', entry) - if descr_match: - lib_info["description"] = descr_match.group(1) - footprint_libs.append(lib_info) +def _parse_lib_entry(entry: str) -> LibraryTableEntry | None: + """Read the `(keyword value)` properties of one entry's content. - return symbol_libs, footprint_libs + The first non-empty value of each property counts. Returns None when the + entry has no name. + """ + tokens = [ + _Token(_unescape(match.group(1)), quoted=True) + if match.group(2) is None + else _Token(match.group(2), quoted=False) + for match in _TOKEN_RE.finditer(entry) + ] + found: dict[_Property, str] = {} + for opening, keyword, value, closing in zip( + tokens, tokens[1:], tokens[2:], tokens[3:], strict=False + ): + prop = _PROPERTY_KEYWORDS.get(keyword.text) if not keyword.quoted else None + is_value = value.quoted or value not in {_OPEN, _CLOSE} + if opening == _OPEN and closing == _CLOSE and prop and is_value and value.text: + found.setdefault(prop, value.text) + name = found.get("name") + if name is None: + return None + parsed: LibraryTableEntry = {"name": name} + for key in _OPTIONAL_PROPERTIES: + if key in found: + parsed[key] = found[key] + return parsed + + +def _unescape(text: str) -> str: + """Decode the escapes of a quoted string as KiCad's S-expression reader does. + + KiCad decodes escapes to bytes and reads the result as UTF-8, so this + works on the UTF-8 bytes too; other characters pass through unchanged. + An unknown escape keeps its backslash. + """ + return _ESCAPE_RE.sub(_escaped_bytes, text.encode("utf-8")).decode( + "utf-8", errors="replace" + ) + + +def _escaped_bytes(match: re.Match[bytes]) -> bytes: + r"""Return the bytes that one escape, such as `\n` or `\x41`, stands for.""" + body = match.string[match.start() + 1 : match.end()] # after the backslash + if body in _NAMED_ESCAPES: + return _NAMED_ESCAPES[body] + if body.startswith(b"x"): + return bytes([int(body[1:], 16)]) + if body[0] in _OCTAL_DIGITS: + # KiCad stores the value in a char, keeping the low byte. + return bytes([int(body, 8) & 0xFF]) + return b"\\" + body diff --git a/kicad_lib_manager/utils/git_utils.py b/kicad_lib_manager/utils/git_utils.py index 25faea5..fb40bb6 100644 --- a/kicad_lib_manager/utils/git_utils.py +++ b/kicad_lib_manager/utils/git_utils.py @@ -1,136 +1,98 @@ -""" -Git utility functions for KiCad Library Manager. -Handles Git hooks directory detection and safe hook management. +"""Git hook management for KiCad Library Manager. + +Finds a repository's active hooks directory and merges the KiLM sync hook +into existing hooks without losing their content. """ -import os +import shutil import subprocess -from datetime import datetime from pathlib import Path +from kicad_lib_manager.utils.backup import unique_backup_path -def get_git_hooks_directory(repo_path: Path) -> Path: - """ - Get the active Git hooks directory for a repository. +_BEGIN_MARKER = "# BEGIN KiLM-managed section" +_END_MARKER = "# END KiLM-managed section" - This function detects the correct hooks directory by: - 1. Using git rev-parse --git-path hooks (handles core.hooksPath, worktrees, and common dir) - 2. Falling back to git rev-parse --git-common-dir + hooks - Args: - repo_path: Path to the Git repository +def get_git_hooks_directory(repo_path: Path) -> Path: + """Return the active Git hooks directory of repo_path, creating it if needed. - Returns: - Path to the active hooks directory + Git is asked for the hooks path, which honours core.hooksPath and + worktrees; when that fails, the hooks directory of the common Git + directory is used. Raises: - RuntimeError: If the repository is not a valid Git repository + RuntimeError: repo_path does not exist, Git is not installed, or + repo_path is not a Git repository. """ if not repo_path.exists(): - raise RuntimeError(f"Repository path does not exist: {repo_path}") + msg = f"Repository path does not exist: {repo_path}" + raise RuntimeError(msg) - # Ask Git for the effective hooks directory. Handles core.hooksPath, worktrees, and common dir. + hooks_dir = _rev_parse_path(repo_path, "--git-path", "hooks") + if hooks_dir is None: + common_dir = _rev_parse_path(repo_path, "--git-common-dir") + if common_dir is None: + msg = f"Not a Git repository: {repo_path}" + raise RuntimeError(msg) + hooks_dir = (common_dir / "hooks").resolve() + hooks_dir.mkdir(parents=True, exist_ok=True) + return hooks_dir + + +def _rev_parse_path(repo_path: Path, *args: str) -> Path | None: + """Return the path `git rev-parse` prints for args, or None if it fails. + + A relative path is resolved against repo_path. + """ + git = shutil.which("git") + if git is None: + msg = "Git is not installed or not on PATH" + raise RuntimeError(msg) try: - rp = subprocess.run( - ["git", "-C", str(repo_path), "rev-parse", "--git-path", "hooks"], + result = subprocess.run( # noqa: S603 -- fixed git rev-parse command; repo_path is passed as one argument + [git, "-C", str(repo_path), "rev-parse", *args], capture_output=True, text=True, check=True, ) - hooks_dir = Path(rp.stdout.strip()) - if not hooks_dir.is_absolute(): - hooks_dir = (repo_path / hooks_dir).resolve() - hooks_dir.mkdir(parents=True, exist_ok=True) - return hooks_dir - except subprocess.CalledProcessError as e: - # Fallback: resolve common dir then append hooks - try: - cd = subprocess.run( - ["git", "-C", str(repo_path), "rev-parse", "--git-common-dir"], - capture_output=True, - text=True, - check=True, - ) - common_dir = Path(cd.stdout.strip()) - if not common_dir.is_absolute(): - common_dir = (repo_path / common_dir).resolve() - hooks_dir = common_dir / "hooks" - hooks_dir.mkdir(parents=True, exist_ok=True) - return hooks_dir.resolve() - except subprocess.CalledProcessError: - raise RuntimeError(f"Not a Git repository: {repo_path}") from e + except subprocess.CalledProcessError: + return None + path = Path(result.stdout.strip()) + return path if path.is_absolute() else (repo_path / path).resolve() def backup_existing_hook(hook_path: Path) -> Path: - """ - Create a timestamped backup of an existing hook file. + """Copy hook_path to a timestamped sibling and return the copy's path. - Args: - hook_path: Path to the existing hook file - - Returns: - Path to the backup file + The copy keeps the hook's permission bits, so an executable hook stays + executable, and never overwrites an earlier backup. """ - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - backup_path = hook_path.with_suffix(f".backup.{timestamp}") - - # Copy the file content - backup_path.write_text(hook_path.read_text(encoding="utf-8")) - - # 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) - + backup_path = unique_backup_path(hook_path) + shutil.copy2(hook_path, backup_path) return backup_path def merge_hook_content(existing_content: str, kilm_content: str) -> str: - """ - Safely merge existing hook content with KiLM content. + """Return existing_content with kilm_content as its KiLM-managed section. - Args: - existing_content: Content of existing hook - kilm_content: KiLM hook content to add - - Returns: - Merged hook content + A complete KiLM-managed section in existing_content is replaced in place; + otherwise kilm_content is appended after a blank line. """ - # 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") - start_marker = "# BEGIN KiLM-managed section" - end_marker = "# END KiLM-managed section" - - start_idx = None - end_idx = None - - for i, line in enumerate(lines): - if line.strip() == start_marker: - start_idx = i - elif line.strip() == end_marker: - end_idx = i - break - - 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) - - # Add KiLM content at the end with clear markers + lines = existing_content.split("\n") + stripped = [line.strip() for line in lines] + if _BEGIN_MARKER in stripped: + start = stripped.index(_BEGIN_MARKER) + if _END_MARKER in stripped[start:]: + end = stripped.index(_END_MARKER, start) + return "\n".join([*lines[:start], kilm_content, *lines[end + 1 :]]) + return f"{existing_content.rstrip()}\n\n{kilm_content}" def create_kilm_hook_content() -> str: - """ - Create the standard KiLM hook content with clear markers. - - Returns: - Formatted hook content string - """ - return """# BEGIN KiLM-managed section + """Return the KiLM hook section, which runs `kilm sync`, between its markers.""" + return f"""{_BEGIN_MARKER} # KiCad Library Manager auto-sync hook # Added by kilm add-hook command @@ -141,4 +103,4 @@ def create_kilm_hook_content() -> str: # kilm setup echo "KiCad libraries sync complete." -# END KiLM-managed section""" +{_END_MARKER}""" diff --git a/kicad_lib_manager/utils/kicad_common.py b/kicad_lib_manager/utils/kicad_common.py new file mode 100644 index 0000000..019f30f --- /dev/null +++ b/kicad_lib_manager/utils/kicad_common.py @@ -0,0 +1,67 @@ +"""KiCad's ``kicad_common.json``: typed reads and backed-up atomic writes. + +KiCad keeps environment variables under ``environment.vars`` and the pinned +libraries under ``session.pinned_symbol_libs`` and ``session.pinned_fp_libs``. +The helpers here narrow the parsed JSON to the parts KiLM uses and replace +the file atomically after backing it up, so a failed write never leaves +KiCad with a truncated configuration. +""" + +import json +from pathlib import Path + +from kicad_lib_manager.utils.backup import create_backup +from kicad_lib_manager.utils.file_ops import write_atomically + +JsonObject = dict[str, object] +"""A parsed JSON object.""" + +PINNED_SYMBOLS_KEY = "pinned_symbol_libs" +PINNED_FOOTPRINTS_KEY = "pinned_fp_libs" + + +def as_json_object(value: object) -> JsonObject | None: + """Return a copy of ``value`` with text keys, or None when it is not a dict.""" + if not isinstance(value, dict): + return None + return {str(key): item for key, item in value.items()} + + +def as_list(value: object) -> list[object]: + """Return a copy of ``value`` when it is a list, otherwise an empty list.""" + if not isinstance(value, list): + return [] + return [*value] + + +def session_of(config: JsonObject) -> JsonObject | None: + """Return a copy of the ``session`` object of ``config``, if it has one.""" + return as_json_object(config.get("session")) + + +def pinned_names(session: JsonObject, key: str) -> list[str]: + """Return the library names pinned under ``key`` in ``session``.""" + return [name for name in as_list(session.get(key)) if isinstance(name, str)] + + +def load_json_object(path: Path) -> JsonObject: + """Read the JSON object stored in ``path``. + + Raises: + OSError: The file cannot be read. + ValueError: The file is not JSON or does not hold an object. + """ + data = as_json_object(json.loads(path.read_text(encoding="utf-8"))) + if data is None: + msg = f"{path} does not contain a JSON object" + raise ValueError(msg) + return data + + +def replace_json_object(path: Path, data: JsonObject, *, max_backups: int) -> None: + """Back up ``path``, then replace it atomically with ``data`` as indented JSON. + + ``max_backups`` is the number of backups of ``path`` to keep. + """ + create_backup(path, max_backups) + write_atomically(path, json.dumps(data, indent=2).encode("utf-8")) diff --git a/kicad_lib_manager/utils/metadata.py b/kicad_lib_manager/utils/metadata.py index 07001c7..6045fd5 100644 --- a/kicad_lib_manager/utils/metadata.py +++ b/kicad_lib_manager/utils/metadata.py @@ -1,214 +1,381 @@ +"""Library metadata files: kilm.yaml and .kilm_metadata. + +kilm.yaml describes a symbol/footprint library, .kilm_metadata a 3D model +library. Reading validates the fields KiLM knows: a field with the wrong type +is left out, as if absent. Fields KiLM does not know are kept, so a read, +change, and write cycle preserves what users or other tools added to the file. +Entries that the typed view cannot hold at all (non-string keys and mistyped +known fields) come back separately from `read_github_metadata_file` and +`read_cloud_metadata_file`, for the matching writer to put back. +Writes replace the file atomically. Read and write failures are reported on +stderr and returned as None or False, because every caller treats unreadable +metadata as missing. """ -Metadata management utilities for KiCad Library Manager. -""" + +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"yaml"} import json import re +from collections.abc import Callable, Mapping from pathlib import Path -from typing import Any +from typing import Final, NamedTuple, TypedDict, cast import yaml -from .constants import CLOUD_METADATA_FILE, GITHUB_METADATA_FILE +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import ( + CLOUD_METADATA_FILE, + GITHUB_METADATA_FILE, + MAX_ENV_VAR_NAME_LENGTH, + MODEL_EXTENSIONS, +) +from kicad_lib_manager.utils.file_ops import write_atomically + +__all__ = [ + "CLOUD_METADATA_FILE", + "GITHUB_METADATA_FILE", + "LIBRARY_FOLDERS", + "CloudMetadata", + "CloudMetadataFile", + "GitHubMetadata", + "GitHubMetadataFile", + "LibraryCapabilities", + "count_model_files", + "generate_env_var_name", + "get_default_cloud_metadata", + "get_default_github_metadata", + "library_capabilities", + "read_cloud_metadata", + "read_cloud_metadata_file", + "read_github_metadata", + "read_github_metadata_file", + "write_cloud_metadata", + "write_github_metadata", +] + + +LIBRARY_FOLDERS: Final = ("symbols", "footprints", "templates") +"""The top-level content folders of a symbol/footprint library. + +`LibraryCapabilities` has one flag per folder, in this order. +""" + +class LibraryCapabilities(TypedDict, total=False): + """Which kinds of content a library has, by its top-level directories.""" -def read_github_metadata( - directory: Path, -) -> dict[str, Any] | None: + symbols: bool + footprints: bool + templates: bool + + +class _LibraryMetadata(TypedDict, total=False): + """Fields shared by both metadata files. Every field may be absent. + + env_var names the environment variable that KiCad resolves to the + library path; None or absent means the library has none. """ - Read metadata from a GitHub library directory. - Args: - directory: Path to the GitHub library directory + name: str + description: str + type: str + version: str + env_var: str | None + created_with: str + updated_with: str - Returns: - Dictionary of metadata or None if not found + +class GitHubMetadata(_LibraryMetadata, total=False): + """kilm.yaml of a symbol/footprint library. Every field may be absent.""" + + capabilities: LibraryCapabilities + + +class CloudMetadata(_LibraryMetadata, total=False): + """.kilm_metadata of a 3D model library. Every field may be absent.""" + + model_count: int + + +class GitHubMetadataFile(NamedTuple): + """A kilm.yaml split into its typed fields and the entries they cannot hold. + + unsupported has the entries with non-string keys and the known fields of + the wrong type. Passing it back to `write_github_metadata` keeps them, so + rewriting the file loses nothing. """ - metadata_file = directory / GITHUB_METADATA_FILE - if not metadata_file.exists(): - return None - try: - with metadata_file.open() as f: - metadata = yaml.safe_load(f) + metadata: GitHubMetadata + unsupported: dict[object, object] - if not isinstance(metadata, dict): - return {} - return metadata - except Exception as e: - print(f"Error reading metadata file: {e}") - return None +class CloudMetadataFile(NamedTuple): + """A .kilm_metadata split into its typed fields and the entries they cannot hold. + + unsupported has the known fields of the wrong type (JSON keys are always + strings). Passing it back to `write_cloud_metadata` keeps them, so + rewriting the file loses nothing. + """ + + metadata: CloudMetadata + unsupported: dict[object, object] + + +FieldCheck = Callable[[object], bool] + + +def _is_str(value: object) -> bool: + return isinstance(value, str) + + +def _is_optional_str(value: object) -> bool: + return value is None or isinstance(value, str) + + +def _is_count(value: object) -> bool: + # bool is an int subclass; YAML true is not a count. + return isinstance(value, int) and not isinstance(value, bool) + + +def _is_capabilities(value: object) -> bool: + if not isinstance(value, dict): + return False + flags = cast("dict[object, object]", value) + return all(isinstance(flags.get(key, False), bool) for key in LIBRARY_FOLDERS) + + +_COMMON_FIELDS: dict[str, FieldCheck] = { + "name": _is_str, + "description": _is_str, + "type": _is_str, + "version": _is_str, + "env_var": _is_optional_str, + "created_with": _is_str, + "updated_with": _is_str, +} +_GITHUB_FIELDS: dict[str, FieldCheck] = { + **_COMMON_FIELDS, + "capabilities": _is_capabilities, +} +_CLOUD_FIELDS: dict[str, FieldCheck] = {**_COMMON_FIELDS, "model_count": _is_count} + + +def _split_fields( + document: object, fields: Mapping[str, FieldCheck] +) -> tuple[dict[str, object], dict[object, object]]: + """Split document into its valid string-keyed entries and all the others. + + The others are the non-string keys and the known fields of the wrong type. + A document that is not a mapping counts as empty. + """ + valid: dict[str, object] = {} + others: dict[object, object] = {} + if isinstance(document, dict): + for key, value in cast("dict[object, object]", document).items(): + if isinstance(key, str) and fields.get(key, _is_anything)(value): + valid[key] = value + else: + others[key] = value + return valid, others + + +def _is_anything(_value: object) -> bool: + return True + + +def _with_unsupported( + metadata: Mapping[str, object], unsupported: Mapping[object, object] | None +) -> dict[object, object]: + """Return the document to write: metadata plus the unsupported entries. + + An unsupported entry is left out when metadata has a field of its name, + so a field set by the caller replaces the mistyped one it was read as. + """ + document: dict[object, object] = { + key: value for key, value in (unsupported or {}).items() if key not in metadata + } + document.update(metadata) + return document + + +def _report(message: str) -> None: + get_console(stderr=True).print(message, markup=False, highlight=False) -def write_github_metadata(directory: Path, metadata: dict[str, Any]) -> bool: +def read_github_metadata(directory: Path) -> GitHubMetadata | None: + """Read directory's kilm.yaml. + + Returns: + The metadata (empty when the file holds no mapping), or None when the + file is missing or unreadable. """ - Write metadata to a GitHub library directory. + loaded = read_github_metadata_file(directory) + return None if loaded is None else loaded.metadata + - Args: - directory: Path to the GitHub library directory - metadata: Dictionary of metadata to write +def read_github_metadata_file(directory: Path) -> GitHubMetadataFile | None: + """Read directory's kilm.yaml with the entries its metadata cannot hold. Returns: - True if successful, False otherwise + The file's contents (both parts empty when it holds no mapping), or + None when the file is missing or unreadable. """ metadata_file = directory / GITHUB_METADATA_FILE + if not metadata_file.exists(): + return None + try: + with metadata_file.open(encoding="utf-8") as f: + document: object = yaml.safe_load(f) + except (OSError, ValueError, yaml.YAMLError) as e: + _report(f"Error reading metadata file: {e}") + return None + valid, unsupported = _split_fields(document, _GITHUB_FIELDS) + # The known fields in valid were checked against GitHubMetadata. + return GitHubMetadataFile(cast("GitHubMetadata", valid), unsupported) + + +def write_github_metadata( + directory: Path, + metadata: GitHubMetadata, + unsupported: Mapping[object, object] | None = None, +) -> bool: + """Write metadata to directory's kilm.yaml; return whether it succeeded. + Entries of unsupported (from `read_github_metadata_file`) are written + too, unless metadata has a field of the same name. + """ + document = _with_unsupported(metadata, unsupported) try: - with metadata_file.open("w") as f: - yaml.dump(metadata, f, default_flow_style=False) - return True - except Exception as e: - print(f"Error writing metadata file: {e}") + text = yaml.dump(document, default_flow_style=False) + write_atomically(directory / GITHUB_METADATA_FILE, text.encode("utf-8")) + except (OSError, yaml.YAMLError) as e: + _report(f"Error writing metadata file: {e}") return False + return True -def read_cloud_metadata(directory: Path) -> dict[str, str | int | None] | None: +def read_cloud_metadata(directory: Path) -> CloudMetadata | None: + """Read directory's .kilm_metadata. + + Returns: + The metadata (empty when the file holds no JSON object), or None when + the file is missing or unreadable. """ - Read metadata from a cloud 3D model directory. + loaded = read_cloud_metadata_file(directory) + return None if loaded is None else loaded.metadata + - Args: - directory: Path to the cloud 3D model directory +def read_cloud_metadata_file(directory: Path) -> CloudMetadataFile | None: + """Read directory's .kilm_metadata with the entries its metadata cannot hold. Returns: - Dictionary of metadata or None if not found + The file's contents (both parts empty when it holds no JSON object), + or None when the file is missing or unreadable. """ metadata_file = directory / CLOUD_METADATA_FILE if not metadata_file.exists(): return None - try: - with metadata_file.open() as f: - metadata = json.load(f) - - if not isinstance(metadata, dict): - return {} - - return metadata - except Exception as e: - print(f"Error reading cloud metadata file: {e}") + with metadata_file.open(encoding="utf-8") as f: + document: object = json.load(f) + except (OSError, ValueError) as e: + _report(f"Error reading cloud metadata file: {e}") return None + valid, unsupported = _split_fields(document, _CLOUD_FIELDS) + # The known fields in valid were checked against CloudMetadata. + return CloudMetadataFile(cast("CloudMetadata", valid), unsupported) def write_cloud_metadata( - directory: Path, metadata: dict[str, str | int | None] + directory: Path, + metadata: CloudMetadata, + unsupported: Mapping[object, object] | None = None, ) -> bool: - """ - Write metadata to a cloud 3D model directory. - - Args: - directory: Path to the cloud 3D model directory - metadata: Dictionary of metadata to write + """Write metadata to directory's .kilm_metadata; return whether it succeeded. - Returns: - True if successful, False otherwise + Entries of unsupported (from `read_cloud_metadata_file`) are written + too, unless metadata has a field of the same name. """ - metadata_file = directory / CLOUD_METADATA_FILE - try: - with metadata_file.open("w") as f: - json.dump(metadata, f, indent=2) - return True - except Exception as e: - print(f"Error writing cloud metadata file: {e}") + text = json.dumps(_with_unsupported(metadata, unsupported), indent=2) + write_atomically(directory / CLOUD_METADATA_FILE, text.encode("utf-8")) + except (OSError, TypeError, ValueError) as e: + _report(f"Error writing cloud metadata file: {e}") return False + return True def generate_env_var_name(name: str, prefix: str = "") -> str: - """ - Generate a valid environment variable name from a library name. - - Args: - name: Library name - prefix: Optional prefix for the environment variable + """Derive a valid environment variable name from a library name. - Returns: - Valid environment variable name + The result is upper case, has every character other than A-Z and 0-9 + replaced by "_", starts with prefix and "_" when prefix is given, starts + with a letter ("LIB_" is prepended otherwise), and is cut to + MAX_ENV_VAR_NAME_LENGTH characters. """ - # Remove any non-alphanumeric characters and replace with underscores clean_name = re.sub(r"[^a-zA-Z0-9]", "_", name.upper()) - - # Add prefix if provided if prefix: clean_name = f"{prefix}_{clean_name}" - - # Ensure it starts with a letter - if not clean_name[0].isalpha(): + if not clean_name[:1].isalpha(): clean_name = f"LIB_{clean_name}" + return clean_name[:MAX_ENV_VAR_NAME_LENGTH] - # Ensure it's not too long (some systems have limits) - if len(clean_name) > 50: - clean_name = clean_name[:50] - return clean_name +def get_default_github_metadata(directory: Path) -> GitHubMetadata: + """Metadata for a new library in directory, named after the directory. - -def get_default_github_metadata( - directory: Path, -) -> dict[str, Any]: - """ - Generate default metadata for a GitHub library. - - Args: - directory: Path to the GitHub library directory - - Returns: - Dictionary of metadata + Capabilities come from `library_capabilities`; env_var is + KICAD_LIB_. """ - # Try to get a sensible name from the directory name = directory.name - - # Look for existing folders to determine capabilities - has_symbols = (directory / "symbols").exists() - has_footprints = (directory / "footprints").exists() - has_templates = (directory / "templates").exists() - - # Generate environment variable name for this library - env_var = generate_env_var_name(name, "KICAD_LIB") - return { "name": name, "description": f"KiCad library {name}", "type": "github", "version": "1.0.0", - "env_var": env_var, - "capabilities": { - "symbols": has_symbols, - "footprints": has_footprints, - "templates": has_templates, - }, + "env_var": generate_env_var_name(name, "KICAD_LIB"), + "capabilities": library_capabilities(directory), "created_with": "kilm", "updated_with": "kilm", } -def get_default_cloud_metadata(directory: Path) -> dict[str, str | int | None]: - """ - Generate default metadata for a cloud 3D model directory. +def library_capabilities(directory: Path) -> LibraryCapabilities: + """Return which of the `LIBRARY_FOLDERS` exist in directory.""" + return { + "symbols": (directory / "symbols").exists(), + "footprints": (directory / "footprints").exists(), + "templates": (directory / "templates").exists(), + } - Args: - directory: Path to the cloud 3D model directory - Returns: - Dictionary of metadata +def count_model_files(directory: Path) -> int: + """Return the number of files below ``directory`` named like 3D models. + + Matches MODEL_EXTENSIONS case-sensitively at any depth, in one walk. """ - # Try to get a sensible name from the directory - name = directory.name + return sum( + 1 + for file in directory.rglob("*") + if file.suffix in MODEL_EXTENSIONS and file.is_file() + ) - # Count 3D model files - model_count = 0 - for ext in [".step", ".stp", ".wrl", ".wings"]: - model_count += len(list(directory.glob(f"**/*{ext}"))) - # Generate a unique environment variable name for this 3D model library - env_var = generate_env_var_name(name, "KICAD_3D") +def get_default_cloud_metadata(directory: Path) -> CloudMetadata: + """Metadata for a new 3D model library in directory, named after the directory. + model_count counts the model files anywhere below directory; env_var is + KICAD_3D_. + """ + name = directory.name + model_count = count_model_files(directory) return { "name": name, "description": f"KiCad 3D model library {name}", "type": "cloud", "version": "1.0.0", - "env_var": env_var, + "env_var": generate_env_var_name(name, "KICAD_3D"), "model_count": model_count, "created_with": "kilm", "updated_with": "kilm", diff --git a/kicad_lib_manager/utils/template.py b/kicad_lib_manager/utils/template.py deleted file mode 100644 index 999230d..0000000 --- a/kicad_lib_manager/utils/template.py +++ /dev/null @@ -1,1344 +0,0 @@ -""" -Template utility functions for KiCad Library Manager. -Provides functions for working with project templates. -""" - -import importlib.util -import json -import os -import re -import shutil -import traceback -from collections.abc import Mapping -from pathlib import Path -from typing import Any, NamedTuple, TypedDict, cast - -import click -import jinja2 -import pathspec -import yaml - -from .constants import ( - HOOKS_DIR, - TEMPLATE_CONTENT_DIR, - TEMPLATE_METADATA, - TEMPLATES_DIR, -) - -# Post-creation hook name -POST_CREATE_HOOK = "post_create.py" - -# Default patterns to look for potentially variable values -DEFAULT_VARIABLE_PATTERNS = [ - # Look for project title/name in KiCad project file - r'"title": "([^"]+)"', - # Common project name patterns - r'project_name\s*[=:]\s*["\']([^"\']+)["\']', - r'projectName\s*[=:]\s*["\']([^"\']+)["\']', - r'PROJECT_NAME\s*[=:]\s*["\']([^"\']+)["\']', - # Look for author info - r'author\s*[=:]\s*["\']([^"\']+)["\']', -] - -# KiCad file extensions -KICAD_PROJECT_EXT = ".kicad_pro" -KICAD_SCHEMATIC_EXT = ".kicad_sch" -KICAD_PCB_EXT = ".kicad_pcb" -KICAD_PRL_EXT = ".kicad_prl" # Project local settings file - -# Windows-compatible filename templating constants -FILENAME_VAR_PATTERN = re.compile(r"%\{([^}]+)\}") - -# Binary file extensions that should not be rendered as templates -BINARY_EXTENSIONS = frozenset( - { - ".png", - ".jpg", - ".jpeg", - ".gif", - ".bmp", - ".ico", - ".pdf", - ".zip", - ".gz", - ".tar", - ".7z", - ".rar", - ".exe", - ".dll", - ".so", - ".pyc", - ".pyo", - ".obj", - ".lib", - ".a", - } -) - -# OS-generated system files that should never be processed in templates -SYSTEM_FILES_TO_IGNORE = frozenset( - { - ".DS_Store", - ".Spotlight-V100", - ".Trashes", - ".fseventsd", - ".AppleDouble", - "Thumbs.db", # Windows thumbnail cache - "desktop.ini", # Windows desktop configuration - } -) - - -class TemplateVariable(TypedDict): - """Template variable definition.""" - - description: str - default: str - - -class _PlannedFile(NamedTuple): - """A template file and the project path it renders to.""" - - source: Path - target: Path - is_binary: bool - - -class TemplateDependencies(TypedDict): - """Template dependencies definition.""" - - recommended: list[str] - - -class TemplateMetadata(TypedDict, total=False): - """Template metadata structure.""" - - name: str - description: str - use_case: str - version: str - variables: dict[str, TemplateVariable] - extends: str - dependencies: TemplateDependencies - path: str - source_library: str - library_path: str - - -def get_gitignore_spec(directory: Path) -> pathspec.PathSpec | None: - """ - Get a PathSpec object representing the gitignore patterns in the given directory. - - Args: - directory: Path to the directory containing .gitignore - - Returns: - PathSpec object or None if no .gitignore is present - """ - gitignore_file = directory / ".gitignore" - if not gitignore_file.exists(): - return None - - try: - with Path(gitignore_file).open() as f: - lines = f.readlines() - - # Add patterns to explicitly ignore common directories if not present - common_patterns = [ - "__pycache__/", - "*.py[cod]", - "*$py.class", - "*.so", - "*.dylib", - ".env", - ".venv", - "env/", - "venv/", - "ENV/", - "node_modules/", - ".DS_Store", - ] - - # Add common patterns if not already in gitignore - for pattern in common_patterns: - if not any(pattern in line for line in lines): - lines.append(pattern + "\n") - - return pathspec.PathSpec.from_lines("gitwildmatch", lines) - except Exception as e: - # Use click.echo for warnings/errors - click.echo(f"Warning: Error reading .gitignore file: {e}", err=True) - return None - - -def list_templates_in_directory(directory: Path) -> list[TemplateMetadata]: - """ - List all templates in a given directory. - - Args: - directory: Path to the directory to scan for templates - - Returns: - List of dictionaries containing template metadata - """ - templates_dir = directory / TEMPLATES_DIR - if not templates_dir.exists() or not templates_dir.is_dir(): - return [] - - templates: list[TemplateMetadata] = [] - - for template_dir in templates_dir.iterdir(): - if not template_dir.is_dir(): - continue - - metadata_file = template_dir / TEMPLATE_METADATA - if not metadata_file.exists(): - continue - - try: - with Path(metadata_file).open() as f: - metadata = yaml.safe_load(f) - - if not metadata or not isinstance(metadata, dict): - continue - - # Add template directory path to metadata - metadata["path"] = str(template_dir) - metadata["source_library"] = directory.name - - templates.append(cast("TemplateMetadata", metadata)) - except Exception as e: - # Use click.echo for warnings/errors - click.echo( - f"Error reading template metadata from {metadata_file}: {e}", err=True - ) - - return templates - - -def find_potential_variables( - directory: Path, patterns: list[str] | None = None -) -> dict[str, list[str]]: - """ - Scan files in a directory for potential template variables. - - Args: - directory: Path to the directory to scan - patterns: List of regex patterns to look for, defaults to DEFAULT_VARIABLE_PATTERNS - - Returns: - Dictionary mapping variable names to found values - """ - if patterns is None: - patterns = DEFAULT_VARIABLE_PATTERNS - - # Compile patterns - compiled_patterns = [re.compile(pattern) for pattern in patterns] - - # Initialize results - variables = {} - - # Get .gitignore spec if it exists - gitignore_spec = get_gitignore_spec(directory) - - # Walk directory - for root, _, files in os.walk(directory): - rel_root = os.path.relpath(root, directory) - if rel_root == ".": - rel_root = "" - - for file in files: - # Skip gitignored files - ensure proper path format - rel_path = str(Path(rel_root) / file) if rel_root else file - git_path = rel_path.replace(os.sep, "/") - if gitignore_spec and gitignore_spec.match_file(git_path): - continue - - # Skip binary files and large files - file_path = str(Path(root) / file) - try: - # Skip files larger than 1MB - if Path(file_path).stat().st_size > 1024 * 1024: - continue - - # Try to read as text - with Path(file_path).open(encoding="utf-8") as f: - content = f.read() - - # Search for patterns - for pattern in compiled_patterns: - matches = pattern.findall(content) - for match in matches: - if match: - var_name = re.sub(r"[^a-zA-Z0-9_]", "_", match.lower()) - if var_name not in variables: - variables[var_name] = [] - if match not in variables[var_name]: - variables[var_name].append(match) - except Exception: - # Skip files that can't be read as text - continue - - return variables - - -def create_template_metadata( - name: str, - description: str | None = None, - use_case: str | None = None, - variables: dict[str, TemplateVariable] | None = None, - extends: str | None = None, - dependencies: list[str] | None = None, -) -> TemplateMetadata: - """ - Create template metadata dictionary. - - Args: - name: Template name - description: Template description - use_case: Template use case - variables: Dictionary of template variables - extends: Parent template name - dependencies: List of recommended dependencies - - Returns: - Template metadata dictionary - """ - # Default variables if none provided - if not variables: - variables = { - "project_name": TemplateVariable( - description="Project name (used in documentation and KiCad files)", - default=name, - ), - "directory_name": TemplateVariable( - description="Directory/repository name (used for folder structure)", - default="%{project_name.lower.replace(' ', '-')}", - ), - "project_filename": TemplateVariable( - description="Main KiCad project filename (without extension)", - default="%{project_name}", - ), - } - else: - # Make sure we have the predefined variables - if not any(k.lower() == "project_name" for k in variables): - variables["project_name"] = TemplateVariable( - description="Project name (used in documentation and KiCad files)", - default=name, - ) - - if not any(k.lower() == "directory_name" for k in variables): - variables["directory_name"] = TemplateVariable( - description="Directory/repository name (used for folder structure)", - default="%{project_name.lower.replace(' ', '-')}", - ) - - if not any(k.lower() == "project_filename" for k in variables): - variables["project_filename"] = TemplateVariable( - description="Main KiCad project filename (without extension)", - default="%{project_name}", - ) - - metadata: TemplateMetadata = { - "name": name, - "description": description or f"KiCad project template for {name}", - "use_case": use_case or "", - "version": "1.0.0", - "variables": variables, - } - - if extends: - metadata["extends"] = extends - - if dependencies: - metadata["dependencies"] = TemplateDependencies(recommended=dependencies) - - return metadata - - -def write_template_metadata(directory: Path, metadata: TemplateMetadata) -> None: - """ - Write template metadata to a file. - - Args: - directory: Template directory - metadata: Metadata dictionary - - Raises: - IOError: If the file can't be written - """ - metadata_file = directory / TEMPLATE_METADATA - - with Path(metadata_file).open("w") as f: - yaml.dump(metadata, f, default_flow_style=False) - - -def process_markdown_file( - source_file: Path, target_file: Path, variables: dict[str, TemplateVariable] -) -> None: - """ - Process a Markdown file to add a template header with available variables. - - Args: - source_file: Source file path - target_file: Target file path - variables: Dictionary of template variables - """ - try: - with Path(source_file).open(encoding="utf-8") as f: - content = f.read() - - # Add a Jinja comment and variable reference section at the top - header = "{# This file is a Jinja template. The section below will be removed when creating a project. #}\n\n" - header += "{% if false %}\n" # Trick to make the following section removable - header += "# Template Variables\n\n" - header += "This template supports the following variables:\n\n" - - # List all available variables with their descriptions - for var_name, var_info in variables.items(): - description = var_info.get("description", f"Value for {var_name}") - default = var_info.get("default", "") - header += f'- `{{ {var_name} }}`: {description} (default: "{default}")\n' - - header += "\nUse these variables in your content with `{{ variable_name }}`.\n" - header += "{% endif %}\n" - - # Create new content with header and original content - new_content = header + content - - # Write to .jinja2 file - target_file_jinja = Path(str(target_file) + ".jinja2") - with Path(target_file_jinja).open("w", encoding="utf-8") as f: - f.write(new_content) - - click.echo(f"Processed Markdown file: {source_file.name}") - - except Exception as e: - click.echo(f"Error processing Markdown file {source_file.name}: {e}", err=True) - # Fallback to direct copy - shutil.copy2(source_file, target_file) - - -def create_template_structure( - source_directory: Path, - template_directory: Path, - metadata: TemplateMetadata, - gitignore_spec: pathspec.PathSpec | None = None, - additional_excludes: list[str] | None = None, -) -> None: - """ - Create template structure from source directory. - - Args: - source_directory: Source directory containing the project - template_directory: Target directory for the template - metadata: Template metadata - gitignore_spec: PathSpec object representing gitignore patterns - additional_excludes: Additional patterns to exclude - - Returns: - None - """ - # Create template directories - template_content_dir = template_directory / TEMPLATE_CONTENT_DIR - hooks_dir = template_directory / HOOKS_DIR - - template_content_dir.mkdir(parents=True, exist_ok=True) - hooks_dir.mkdir(parents=True, exist_ok=True) - - # Write metadata - write_template_metadata(template_directory, metadata) - - # Create hook script - create_hook_script(hooks_dir) - - # Create additional spec for excluded files - additional_spec = None - if additional_excludes: - additional_spec = pathspec.PathSpec.from_lines( - "gitwildmatch", additional_excludes - ) - - # Define common directories to exclude - common_excludes = [ - "venv", - "env", - ".venv", - ".env", - ".git", - "node_modules", - "__pycache__", - "dist", - "build", - ".pytest_cache", - ".idea", - ".vscode", - TEMPLATES_DIR, # Avoid recursive copying of templates directory - ] - - # Debug gitignore if available - if gitignore_spec: - click.echo("Using .gitignore patterns for exclusion") - - # Max path length to prevent "file name too long" errors - MAX_PATH_LENGTH = 512 - - # Track main KiCad files - main_project_file: str | None = None - main_schematic_file: str | None = None - main_pcb_file: str | None = None - - # First scan to find main KiCad files - click.echo("Scanning for main KiCad files...") - for root, _, files in os.walk(source_directory): - rel_root = os.path.relpath(root, source_directory) - - # Skip if in excluded directory - if any(excl in rel_root for excl in common_excludes): - continue - - # Check for gitignore matches - ensure proper path formatting for directories - git_path = rel_root.replace(os.sep, "/") - if not git_path.endswith("/") and git_path != ".": - git_path += "/" - if gitignore_spec and gitignore_spec.match_file(git_path): - continue - - # Look for KiCad files in the root directory - 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 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_name.lower() - - if file_lower.endswith(KICAD_PROJECT_EXT) and not main_project_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_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_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): - rel_root = os.path.relpath(root, source_directory) - if rel_root == ".": - rel_root = "" - - # Skip directories that should be excluded - dirs_to_remove = [] - for d in dirs: - # Skip template directories and virtual environments - if d in common_excludes: - dirs_to_remove.append(d) - click.echo(f"Excluding common directory: {d}") - continue - - # Check if this is a path we would exclude - rel_path = Path(rel_root) / d - - # Ensure proper path format for gitignore matching - # (pathspec expects paths with forward slashes and trailing slash for directories) - git_path = str(rel_path).replace(os.sep, "/") - if not git_path.endswith("/"): - git_path += "/" - - # Prevent copying into template directory to avoid recursion - if template_directory.as_posix() in str(Path(source_directory) / rel_path): - dirs_to_remove.append(d) - click.echo(f"Preventing recursive template copy: {rel_path}") - continue - - # Check for gitignore and custom exclusions - if gitignore_spec and gitignore_spec.match_file(git_path): - dirs_to_remove.append(d) - click.echo(f"Excluding directory from gitignore: {git_path}") - elif additional_spec and additional_spec.match_file(git_path): - dirs_to_remove.append(d) - click.echo(f"Excluding directory from additional patterns: {git_path}") - - for d in dirs_to_remove: - if d in dirs: # Extra check to avoid KeyError - dirs.remove(d) - - # Create target directory - if rel_root: - target_dir = template_content_dir / rel_root - - # Check path length - if len(str(target_dir)) > MAX_PATH_LENGTH: - click.echo(f"Skipping directory due to path length: {rel_root}") - continue - - Path(target_dir).mkdir(parents=True, exist_ok=True) - else: - target_dir = template_content_dir - - # Copy files - for file in files: - rel_path = Path(rel_root) / file - - # Ensure proper path format for gitignore matching - 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): - click.echo(f"Excluding file from gitignore: {git_path}") - continue - if additional_spec and additional_spec.match_file(git_path): - click.echo(f"Excluding file from additional patterns: {git_path}") - continue - - # Skip .kicad_prl files - they are project-specific preferences - if file.lower().endswith(KICAD_PRL_EXT): - click.echo(f"Excluding KiCad project preferences file: {git_path}") - continue - - source_file = source_directory / rel_path - - # Special handling for main KiCad files and markdown - file_path = Path(root) / file - - # Generate target file path - # For main KiCad files, use generic names with Windows-compatible syntax - if main_project_file and file_path == main_project_file: - target_file = ( - template_content_dir / rel_root / "%{project_filename}.kicad_pro" - ) - elif main_schematic_file and file_path == main_schematic_file: - target_file = ( - template_content_dir / rel_root / "%{project_filename}.kicad_sch" - ) - elif main_pcb_file and file_path == main_pcb_file: - target_file = ( - template_content_dir / rel_root / "%{project_filename}.kicad_pcb" - ) - else: - target_file = template_content_dir / rel_path - - # Check path length - if len(str(target_file)) > MAX_PATH_LENGTH: - click.echo(f"Skipping file due to path length: {rel_path}") - continue - - # Process main project file - if main_project_file and file_path == main_project_file: - process_kicad_project_file( - source_file, target_file, metadata.get("variables", {}) - ) - - # Process main schematic file - elif main_schematic_file and file_path == main_schematic_file: - process_kicad_schematic_file(source_file, target_file) - - # Process main PCB file - elif main_pcb_file and file_path == main_pcb_file: - process_kicad_pcb_file(source_file, target_file) - - # Process Markdown files - elif source_file.name.lower().endswith(".md"): - process_markdown_file( - source_file, target_file, metadata.get("variables", {}) - ) - - # All other files are just copied - else: - try: - shutil.copy2(source_file, target_file) - except OSError as e: - click.echo(f"Error copying file {rel_path}: {e}", err=True) - - -def create_hook_script(hooks_dir: Path) -> None: - """ - Create a basic post-creation hook script. - - Args: - hooks_dir: Hooks directory - """ - hook_script = hooks_dir / POST_CREATE_HOOK - - script_content = '''""" -Post-creation hook for template. -This script runs after the template has been used to create a new project. -""" - -def post_create(context): - """ - Hook that runs after project creation. - - Args: - context: Dict containing: - - project_dir: Path to the created project - - variables: Dict of all template variables and their values - - template: Template metadata - - Returns: - None - """ - # Uncomment and modify as needed - # import subprocess - # import os - - # Example: Initialize git repository - # subprocess.run(["git", "init"], cwd=context["project_dir"]) - - # Example: Create README.md if it doesn't exist - # readme_path = os.path.join(context["project_dir"], "README.md") - # if not os.path.exists(readme_path): - # with Path(readme_path).open("w") as f: - # f.write(f"# {context['variables']['project_name']}\n\n") - # f.write("Created with KiCad Library Manager\n") - - # Print message to user - click.echo(f"Project {context['variables']['project_name']} created successfully!") - click.echo(f"Location: {context['project_dir']}") -''' - - with Path(hook_script).open("w") as f: - f.write(script_content) - - -def process_kicad_project_file( - source_file: Path, target_file: Path, variables: dict[str, TemplateVariable] -) -> None: - """ - Process a KiCad project file (.kicad_pro). - - Args: - source_file: Source file path - target_file: Target file path - variables: Dictionary of template variables - """ - try: - with Path(source_file).open(encoding="utf-8") as f: - content = f.read() - - # Get project name variable - project_name_var = None - for var_name, _var_info in variables.items(): - if var_name.lower() in ("project_name", "projectname", "project"): - project_name_var = var_name - break - - # Parse JSON project file - try: - project_data = json.loads(content) - - # Replace project title if found - if ( - project_name_var - and "meta" in project_data - and "filename" in project_data["meta"] - ): - original_filename = project_data["meta"]["filename"] - # Replace with project_filename variable - using {{ project_name }}.kicad_pro pattern - this is inside the .jinja2 file, not in the filename so this is actually a correct syntax - project_data["meta"]["filename"] = "{{ project_filename }}.kicad_pro" - click.echo( - f" Templated project filename: '{original_filename}' → '{{ project_filename }}.kicad_pro'" - ) - - # Write updated JSON to a .jinja2 file - target_file_jinja = Path(str(target_file) + ".jinja2") - with Path(target_file_jinja).open("w", encoding="utf-8") as f: - json.dump(project_data, f, indent=2) - - click.echo( - f"Processed KiCad project file: {source_file.name} → {target_file_jinja.name}" - ) - - except json.JSONDecodeError: - click.echo( - f"Warning: Could not parse {source_file.name} as JSON. Copying without changes." - ) - shutil.copy2(source_file, target_file) - - except Exception as e: - click.echo( - f"Error processing KiCad project file {source_file.name}: {e}", err=True - ) - # Fallback to direct copy - shutil.copy2(source_file, target_file) - - -def process_kicad_schematic_file(source_file: Path, target_file: Path) -> None: - """ - Process a KiCad schematic file (.kicad_sch). - - Args: - source_file: Source file path - target_file: Target file path - """ - try: - with Path(source_file).open(encoding="utf-8") as f: - content = f.read() - - # Replace project name references in schematic file - # Replace project references - project_pattern = re.compile(r'\(project\s+"([^"]+)"') - content = project_pattern.sub(r'(project "{{ project_filename }}"', content) - click.echo( - " Templated schematic project name references with '{{ project_filename }}'" - ) - - # Write to .jinja2 file - target_file_jinja = Path(str(target_file) + ".jinja2") - with Path(target_file_jinja).open("w", encoding="utf-8") as f: - f.write(content) - - click.echo( - f"Processed KiCad schematic file: {source_file.name} → {target_file_jinja.name}" - ) - - except Exception as e: - click.echo( - f"Error processing KiCad schematic file {source_file.name}: {e}", err=True - ) - # Fallback to direct copy - shutil.copy2(source_file, target_file) - - -def process_kicad_pcb_file(source_file: Path, target_file: Path) -> None: - """ - Process a KiCad PCB file (.kicad_pcb). - - Args: - source_file: Source file path - target_file: Target file path - """ - try: - with Path(source_file).open(encoding="utf-8") as f: - content = f.read() - - # Find and replace sheet references - sheet_pattern = re.compile(r'\(sheetfile\s+"([^"]+)"') - original_filename = source_file.stem - - # Replace sheet references with template variable - def sheet_replacer(match): - sheet_name = match.group(1) - if original_filename in sheet_name: - # Replace with template variable - return '(sheetfile "{{ project_filename }}.kicad_sch"' - return match.group(0) - - content = sheet_pattern.sub(sheet_replacer, content) - click.echo( - " Templated PCB sheet references with '{{ project_filename }}.kicad_sch'" - ) - - # Write to .jinja2 file - target_file_jinja = Path(str(target_file) + ".jinja2") - with Path(target_file_jinja).open("w", encoding="utf-8") as f: - f.write(content) - - click.echo( - f"Processed KiCad PCB file: {source_file.name} → {target_file_jinja.name}" - ) - - except Exception as e: - click.echo(f"Error processing KiCad PCB file {source_file.name}: {e}", err=True) - # Fallback to direct copy - shutil.copy2(source_file, target_file) - - -# Add new functions for rendering templates and creating projects from templates -def render_template_string( - template_str: str, variables: Mapping[str, str | int | bool] -) -> str: - """ - Render a template string using Jinja2 or custom Windows-compatible templating. - - First tries the custom Windows-compatible %{variable} syntax, then falls back to Jinja2. - - Args: - template_str: The template string to render - variables: Dictionary of variables to use in rendering - - Returns: - Rendered string - """ - # First try custom Windows-compatible templating - if FILENAME_VAR_PATTERN.search(template_str): - return render_filename_custom(template_str, variables) - - # Fall back to Jinja2 for backwards compatibility - try: - template = jinja2.Template(template_str) - return template.render(**variables) - except jinja2.exceptions.TemplateError as e: - click.echo(f"Warning: Failed to render template: {e}", err=True) - return template_str - - -def render_filename_custom( - filename: str, variables: Mapping[str, str | int | bool] -) -> str: - """ - Render a filename using a custom Windows-compatible templating system. - - Uses %{variable_name} syntax instead of {{variable_name}} to avoid Windows filename restrictions. - Supports transformations like: - - %{project_name.lower} - converts to lowercase - - %{project_name.upper} - converts to uppercase - - %{project_name.replace(' ', '-')} - replaces spaces with dashes - - %{project_name.replace(' ', '_').lower} - chain transformations - - Args: - filename: The filename to render - variables: Dictionary of variables to use in rendering - - Returns: - Rendered filename - """ - - def transform_value(value: str, transformations: list[str]) -> str: - """Apply a chain of transformations to a value.""" - result = value - - for transform in transformations: - transform = transform.strip() - - if transform == "lower": - result = result.lower() - elif transform == "upper": - result = result.upper() - elif transform.startswith("replace("): - # Parse replace(old, new) transformation - try: - # Extract the arguments from replace(old, new) - args_str = transform[8:-1] # Remove 'replace(' and ')' - - # Simple approach: split by comma and handle quotes properly - # For the format replace(' ', '-') or replace(' ', '_') - if args_str.count(",") == 1: - parts = args_str.split(",", 1) - old_val = parts[0].strip().strip("'\"") - new_val = parts[1].strip().strip("'\"") - result = result.replace(old_val, new_val) - else: - click.echo( - f"Warning: Invalid replace transformation format: {transform}", - err=True, - ) - - except Exception as e: - click.echo( - f"Warning: Error parsing replace transformation '{transform}': {e}", - err=True, - ) - else: - click.echo(f"Warning: Unknown transformation '{transform}'", err=True) - - return result - - def replacer(match): - """Replace a %{variable.transform1.transform2} pattern with the transformed value.""" - var_expr = match.group(1) - - # Split by dot to separate variable name from transformations - parts = var_expr.split(".") - var_name = parts[0].strip() - transformations = parts[1:] if len(parts) > 1 else [] - - # Get the variable value - if var_name in variables: - value = str(variables[var_name]) - - # Apply transformations - if transformations: - value = transform_value(value, transformations) - - return value - else: - click.echo( - f"Warning: Variable '{var_name}' not found in template variables", - err=True, - ) - return match.group(0) # Return original if variable not found - - # Only try to render if the filename contains our custom template pattern - if FILENAME_VAR_PATTERN.search(filename): - try: - return FILENAME_VAR_PATTERN.sub(replacer, filename) - except Exception as e: - click.echo(f"Warning: Failed to render filename {filename}: {e}", err=True) - - return filename - - -def render_filename(filename: str, variables: Mapping[str, str | int | bool]) -> str: - """ - Render a filename using either Jinja2 or custom Windows-compatible templating. - - First tries the custom Windows-compatible %{variable} syntax, then falls back to Jinja2. - - Args: - filename: The filename to render - variables: Dictionary of variables to use in rendering - - Returns: - Rendered filename - """ - # First try custom Windows-compatible templating - if FILENAME_VAR_PATTERN.search(filename): - return render_filename_custom(filename, variables) - - # Fall back to Jinja2 for backwards compatibility - if "{{" in filename and "}}" in filename: - try: - # Create a proper Jinja2 environment - env = jinja2.Environment(undefined=jinja2.StrictUndefined) - template = env.from_string(filename) - return template.render(**variables) - except jinja2.exceptions.TemplateError as e: - click.echo(f"Warning: Failed to render filename {filename}: {e}", err=True) - - return filename - - -def find_all_templates(config: Any) -> dict[str, TemplateMetadata]: - """ - Find all templates in all configured libraries. - - Args: - config: The configuration object - - Returns: - Dictionary mapping template names to template metadata - """ - all_templates: dict[str, TemplateMetadata] = {} - all_libraries = config.get_libraries() # type: ignore[attr-defined] - - for library in all_libraries: - library_path = library.get("path") - if not library_path or not Path(library_path).exists(): - continue - - templates = list_templates_in_directory(Path(library_path)) - for template in templates: - name = template.get("name") - if not name: - # A template without a name cannot be selected, so it is not listed. - continue - if name not in all_templates: - # Add the template - all_templates[name] = template - - # Add source library information - all_templates[name]["source_library"] = library.get("name", "unknown") - all_templates[name]["library_path"] = library_path - else: - # Libraries are searched in configured order, so the first one wins. - click.echo( - f"Warning: Duplicate template name '{name}' found.", err=True - ) - click.echo(f" Using: {all_templates[name]['path']}", err=True) - click.echo(f" Ignoring: {template['path']}", err=True) - - return all_templates - - -def render_template_file( - source_file: Path, - target_file: Path, - variables: Mapping[str, str | int | bool], - is_binary: bool = False, -) -> bool: - """ - Render a template file to a target file. - - Args: - source_file: Source file path - target_file: Target file path - variables: Variables to use in rendering - is_binary: Whether the file is binary (should not be rendered) - - Returns: - True if successful, False otherwise - """ - # Create target directory if it doesn't exist - target_file.parent.mkdir(parents=True, exist_ok=True) - - if is_binary: - # Simply copy binary files - try: - shutil.copy2(source_file, target_file) - return True - except Exception as e: - click.echo(f"Error copying binary file {source_file}: {e}", err=True) - return False - - # For text files, render them if they have a .jinja2 extension - try: - file_content = "" - with Path(source_file).open(encoding="utf-8") as f: - file_content = f.read() - - # If it's a Jinja template, process it - if source_file.name.endswith(".jinja2"): - # Create Jinja2 template environment with safe auto-escaping - env = jinja2.Environment( - loader=jinja2.FileSystemLoader(source_file.parent), - autoescape=jinja2.select_autoescape(["html", "xml"]), - undefined=jinja2.StrictUndefined, - ) - - # Parse the template - try: - template = env.from_string(file_content) - rendered_content = template.render(**variables) - - # Write rendered content to target file (without .jinja2 extension) - target_path_str = str(target_file) - if target_path_str.endswith(".jinja2"): - target_path_str = target_path_str[:-7] # Remove ".jinja2" - target_path = Path(target_path_str) - with Path(target_path).open("w", encoding="utf-8") as f: - f.write(rendered_content) - - return True - except jinja2.exceptions.TemplateError as e: - click.echo( - f"Error rendering template {source_file.name}: {e}", err=True - ) - # Fall back to copying the file as-is - shutil.copy2(source_file, target_file) - return False - else: - # Not a template file, just copy it - shutil.copy2(source_file, target_file) - return True - - except Exception as e: - click.echo(f"Error processing file {source_file}: {e}", err=True) - # Try to copy anyway - try: - shutil.copy2(source_file, target_file) - return True - except Exception as e: - click.echo(f"Error copying file {source_file}: {e}", err=True) - return False - - -def create_project_from_template( - template_dir: Path, - project_dir: Path, - variables: Mapping[str, str | int | bool], - metadata: dict[str, Any] | None = None, - dry_run: bool = False, - skip_hooks: bool = False, -) -> bool: - """ - Create a new project from a template. - - Args: - template_dir: Path to the template directory - project_dir: Path to create the project in - variables: Dictionary of template variables - metadata: Template metadata - dry_run: If True, show what would be created without making changes - skip_hooks: If True, skip running post-creation hooks - - Returns: - True if successful, False otherwise - """ - template_content_dir = template_dir / TEMPLATE_CONTENT_DIR - - if not template_content_dir.exists() or not template_content_dir.is_dir(): - click.echo( - f"Error: Template content directory {template_content_dir} does not exist", - err=True, - ) - return False - - # Check if project directory exists and is not empty - if project_dir.exists(): - files = list(project_dir.iterdir()) - if files and not dry_run: - click.echo( - f"Warning: Target directory {project_dir} is not empty", err=True - ) - # Continue anyway - we'll merge the template with the existing directory - - # Use provided metadata if available, otherwise load it (fallback) - if metadata is None: - metadata_file = template_dir / TEMPLATE_METADATA - if not metadata_file.exists(): - click.echo( - f"Error: Template metadata file {metadata_file} does not exist and was not provided.", - err=True, - ) - return False - try: - with Path(metadata_file).open() as f: - metadata = yaml.safe_load(f) - if not metadata: # Handle empty metadata file - click.echo( - f"Warning: Template metadata file is empty: {metadata_file}", - err=True, - ) - metadata = {} # Use empty dict - except Exception as e: - click.echo( - f"Error reading template metadata {metadata_file}: {e}", err=True - ) - return False - - # Check if we need to merge with variables from a parent template - if "extends" in metadata and metadata["extends"]: - # TODO: Handle template inheritance - click.echo("Warning: Template inheritance not fully implemented yet", err=True) - - # Collect files to create - files_to_create: list[_PlannedFile] = [] - - # Walk through the template directory - for root, _dirs, files in os.walk(template_content_dir): - rel_root = os.path.relpath(root, template_content_dir) - if rel_root == ".": - rel_root = "" - - # Create target directory path - if rel_root: - # Render directory name if it contains template variables - rendered_dir_path = render_filename(rel_root, variables) - target_dir = project_dir / rendered_dir_path - - if dry_run: - click.echo(f"Would create directory: {target_dir}") - else: - target_dir.mkdir(parents=True, exist_ok=True) - else: - target_dir = project_dir - - # Process files - for file in files: - # Skip system files that should never be in templates - if file in SYSTEM_FILES_TO_IGNORE: - continue - - source_file = Path(root) / file - - # Strip .jinja2 extension from target file if present - target_filename = file - if target_filename.endswith(".jinja2"): - target_filename = target_filename[:-7] - - # Render the filename if it contains template variables - rendered_filename = render_filename(target_filename, variables) - - # Create target file path - if rel_root: - rendered_dir_path = render_filename(rel_root, variables) - target_file = project_dir / rendered_dir_path / rendered_filename - else: - target_file = project_dir / rendered_filename - - # Check if it's a binary file - is_binary = any( - target_filename.lower().endswith(ext) for ext in BINARY_EXTENSIONS - ) - - # Add to the list of files to create - files_to_create.append( - _PlannedFile( - source=source_file, target=target_file, is_binary=is_binary - ) - ) - - # In dry run mode, just show what would be created - if dry_run: - click.echo() # Add spacing - click.echo("The following files would be created:") - for file_info in files_to_create: - click.echo(f" {file_info.target}") - return True - - # Actually create the files - for file_info in files_to_create: - success = render_template_file( - source_file=file_info.source, - target_file=file_info.target, - variables=variables, - is_binary=file_info.is_binary, - ) - - if not success: - click.echo(f"Warning: Failed to create {file_info.target}", err=True) - - # Run post-creation hook if present and not skipped - if not skip_hooks: - hook_script = template_dir / HOOKS_DIR / POST_CREATE_HOOK - if hook_script.exists(): - try: - run_post_create_hook(hook_script, project_dir, variables, metadata) - except Exception as e: - click.echo(f"Error running post-creation hook: {e}", err=True) - traceback.print_exc() - - return True - - -def run_post_create_hook( - hook_script: Path, - project_dir: Path, - variables: Mapping[str, str | int | bool], - template_metadata: dict[str, Any], -) -> None: - """ - Run a post-creation hook script. - - Args: - hook_script: Path to the hook script - project_dir: Path to the created project - variables: Dictionary of template variables - template_metadata: Template metadata - - Raises: - Exception: If the hook script fails - """ - # Check if the hook script exists - if not hook_script.exists(): - return - - # Load the hook script - spec = importlib.util.spec_from_file_location("hook", hook_script) - if not spec or not spec.loader: - click.echo(f"Error: Could not load hook script {hook_script}", err=True) - return - - hook = importlib.util.module_from_spec(spec) - spec.loader.exec_module(hook) - - # Check if the post_create function exists - if not hasattr(hook, "post_create"): - click.echo( - f"Warning: Hook script {hook_script} does not have a post_create function", - err=True, - ) - return - - # Create the context - context = { - "project_dir": str(project_dir), - "variables": variables, - "template": template_metadata, - } - - # Run the post_create function - hook.post_create(context) diff --git a/kicad_lib_manager/utils/template/__init__.py b/kicad_lib_manager/utils/template/__init__.py new file mode 100644 index 0000000..7de4bf1 --- /dev/null +++ b/kicad_lib_manager/utils/template/__init__.py @@ -0,0 +1,8 @@ +"""KiCad project templates: authoring, discovery, rendering, and project creation. + +A template is a directory inside a library's ``templates`` directory holding +``metadata.yaml``, the project files under ``template/``, and optional hook +scripts under ``hooks/``. ``authoring`` builds one from an existing project, +``discovery`` finds them in the configured libraries, and ``project`` renders +one into a new project with ``rendering``. +""" diff --git a/kicad_lib_manager/utils/template/authoring.py b/kicad_lib_manager/utils/template/authoring.py new file mode 100644 index 0000000..17ce11a --- /dev/null +++ b/kicad_lib_manager/utils/template/authoring.py @@ -0,0 +1,173 @@ +"""Making a template from an existing project, for ``template make``.""" + +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"pathspec"} + +import shutil +from collections.abc import Mapping +from pathlib import Path, PurePath + +import click +import pathspec + +from kicad_lib_manager.utils.constants import HOOKS_DIR, TEMPLATE_CONTENT_DIR +from kicad_lib_manager.utils.template.constants import ( + MARKDOWN_EXT, + MAX_TEMPLATE_PATH_LENGTH, + PROJECT_FILENAME_VARIABLE, +) +from kicad_lib_manager.utils.template.hooks import create_hook_script +from kicad_lib_manager.utils.template.kicad_files import ( + process_kicad_pcb_file, + process_kicad_project_file, + process_kicad_schematic_file, + process_markdown_file, +) +from kicad_lib_manager.utils.template.models import ( + TemplateMetadata, + TemplateVariable, + write_template_metadata, +) +from kicad_lib_manager.utils.template.selection import ( + MainFileKind, + plan_template_files, +) + +TEMPLATE_VERSION = "1.0.0" + + +def create_template_metadata( + name: str, + description: str | None = None, + use_case: str | None = None, + variables: Mapping[str, TemplateVariable] | None = None, + extends: str | None = None, +) -> TemplateMetadata: + """Build the metadata of a new template. + + Args: + name: The template name. + description: The description; defaults to one naming the template. + use_case: When to use the template. + variables: The template variables. ``project_name``, + ``directory_name``, and ``project_filename`` are appended when no + variable has that name, ignoring case. + extends: The name of the parent template. + + Returns: + The metadata, at version 1.0.0. + """ + merged = {} if variables is None else dict(variables) + present = {key.lower() for key in merged} + for key, variable in _predefined_variables(name).items(): + if key not in present: + merged[key] = variable + metadata = TemplateMetadata( + name=name, + description=description or f"KiCad project template for {name}", + use_case=use_case or "", + version=TEMPLATE_VERSION, + variables=merged, + ) + if extends is not None and extends: + metadata["extends"] = extends + return metadata + + +def create_template_structure( + source_directory: Path, + template_directory: Path, + metadata: TemplateMetadata, + gitignore_spec: pathspec.PathSpec | None = None, + additional_excludes: list[str] | None = None, +) -> None: + """Write a template made from ``source_directory`` to ``template_directory``. + + Writes the metadata, the starting post-creation hook, and the files that + ``plan_template_files`` selects. The main project, schematic, and PCB + files are renamed after ``project_filename`` and turned into Jinja + templates, as are Markdown files. Paths longer than 512 characters are + skipped. + """ + content_dir = template_directory / TEMPLATE_CONTENT_DIR + hooks_dir = template_directory / HOOKS_DIR + content_dir.mkdir(parents=True, exist_ok=True) + hooks_dir.mkdir(parents=True, exist_ok=True) + write_template_metadata(template_directory, metadata) + create_hook_script(hooks_dir) + + if gitignore_spec is not None: + click.echo("Using .gitignore patterns for exclusion") + click.echo("Scanning for main KiCad files...") + plan = plan_template_files( + source_directory, + template_directory=template_directory, + gitignore_spec=gitignore_spec, + additional_excludes=additional_excludes, + ) + for kind, path in plan.main_files.items(): + click.echo(f"Found main {kind.label} file: {path.name}") + for exclusion in plan.excluded: + click.echo(exclusion.message) + + skipped: set[PurePath] = set() + for relative in plan.directories: + target_dir = content_dir / relative + if len(str(target_dir)) > MAX_TEMPLATE_PATH_LENGTH: + click.echo(f"Skipping directory due to path length: {relative}") + skipped.add(relative) + else: + target_dir.mkdir(parents=True, exist_ok=True) + + variables = metadata.get("variables", {}) + for relative in plan.files: + if relative.parent in skipped: + continue + kind = plan.main_kind(relative) + target = content_dir / relative + if kind is not None: + target = target.with_name(f"%{{{PROJECT_FILENAME_VARIABLE}}}{kind.value}") + if len(str(target)) > MAX_TEMPLATE_PATH_LENGTH: + click.echo(f"Skipping file due to path length: {relative}") + continue + _add_file(source_directory / relative, target, kind, variables) + + +def _add_file( + source: Path, + target: Path, + kind: MainFileKind | None, + variables: Mapping[str, TemplateVariable], +) -> None: + """Copy one source file into the template, templating it when needed.""" + if kind is MainFileKind.PROJECT: + process_kicad_project_file(source, target, variables) + elif kind is MainFileKind.SCHEMATIC: + process_kicad_schematic_file(source, target) + elif kind is MainFileKind.PCB: + process_kicad_pcb_file(source, target) + elif source.name.lower().endswith(MARKDOWN_EXT): + process_markdown_file(source, target, variables) + else: + try: + shutil.copy2(source, target) + except OSError as error: + click.echo(f"Error copying file {source.name}: {error}", err=True) + + +def _predefined_variables(name: str) -> dict[str, TemplateVariable]: + """The variables every template has, with their defaults.""" + return { + "project_name": TemplateVariable( + description="Project name (used in documentation and KiCad files)", + default=name, + ), + "directory_name": TemplateVariable( + description="Directory/repository name (used for folder structure)", + default="%{project_name.lower.replace(' ', '-')}", + ), + PROJECT_FILENAME_VARIABLE: TemplateVariable( + description="Main KiCad project filename (without extension)", + default="%{project_name}", + ), + } diff --git a/kicad_lib_manager/utils/template/constants.py b/kicad_lib_manager/utils/template/constants.py new file mode 100644 index 0000000..86ced80 --- /dev/null +++ b/kicad_lib_manager/utils/template/constants.py @@ -0,0 +1,120 @@ +"""Fixed values for KiCad project templates.""" + +import re + +from kicad_lib_manager.utils.constants import TEMPLATES_DIR + +# Hook script that runs after a project is created, inside the hooks directory. +POST_CREATE_HOOK = "post_create.py" + +# Content files with this suffix are rendered with Jinja2 and lose the suffix. +JINJA_SUFFIX = ".jinja2" + +# KiCad file extensions. +KICAD_PROJECT_EXT = ".kicad_pro" +KICAD_SCHEMATIC_EXT = ".kicad_sch" +KICAD_PCB_EXT = ".kicad_pcb" +# Per-user project preferences, never part of a template. +KICAD_PRL_EXT = ".kicad_prl" +MARKDOWN_EXT = ".md" + +# Variable that names the main KiCad files of a project created from a template. +PROJECT_FILENAME_VARIABLE = "project_filename" +# Names under which a template may define the project name. +PROJECT_NAME_VARIABLES = frozenset({"project_name", "projectname", "project"}) + +# Windows-compatible filename placeholders: %{variable.transform}. +FILENAME_VAR_PATTERN = re.compile(r"%\{([^}]+)\}") + +# Regexes whose first group is a value worth offering as a template variable. +DEFAULT_VARIABLE_PATTERNS = ( + # Project title in a KiCad project file. + r'"title": "([^"]+)"', + # Common project name assignments. + r'project_name\s*[=:]\s*["\']([^"\']+)["\']', + r'projectName\s*[=:]\s*["\']([^"\']+)["\']', + r'PROJECT_NAME\s*[=:]\s*["\']([^"\']+)["\']', + # Author information. + r'author\s*[=:]\s*["\']([^"\']+)["\']', +) +# Files larger than this are not scanned for variables. +MAX_SCANNED_FILE_BYTES = 1024 * 1024 + +# Ignored in addition to a project's .gitignore rules when choosing the files +# of a template; a project without a .gitignore uses neither. +ALWAYS_IGNORED_PATTERNS = ( + "__pycache__/", + "*.py[cod]", + "*$py.class", + "*.so", + "*.dylib", + ".env", + ".venv", + "env/", + "venv/", + "ENV/", + "node_modules/", + ".DS_Store", +) + +# Directory names never copied into a template, at any depth. The templates +# directory is excluded to avoid copying templates into themselves. +EXCLUDED_DIRECTORIES = frozenset( + { + "venv", + "env", + ".venv", + ".env", + ".git", + "node_modules", + "__pycache__", + "dist", + "build", + ".pytest_cache", + ".idea", + ".vscode", + TEMPLATES_DIR, + } +) + +# Longer template paths are skipped to avoid "file name too long" errors. +MAX_TEMPLATE_PATH_LENGTH = 512 + +# Files copied byte for byte instead of being read as text. +BINARY_EXTENSIONS = frozenset( + { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".ico", + ".pdf", + ".zip", + ".gz", + ".tar", + ".7z", + ".rar", + ".exe", + ".dll", + ".so", + ".pyc", + ".pyo", + ".obj", + ".lib", + ".a", + } +) + +# Files that operating systems create, never copied into a project. +SYSTEM_FILES_TO_IGNORE = frozenset( + { + ".DS_Store", + ".Spotlight-V100", + ".Trashes", + ".fseventsd", + ".AppleDouble", + "Thumbs.db", + "desktop.ini", + } +) diff --git a/kicad_lib_manager/utils/template/discovery.py b/kicad_lib_manager/utils/template/discovery.py new file mode 100644 index 0000000..bb1ada2 --- /dev/null +++ b/kicad_lib_manager/utils/template/discovery.py @@ -0,0 +1,97 @@ +"""Discovery of the templates in the configured libraries.""" + +from collections.abc import Sequence +from pathlib import Path +from typing import Protocol, TypedDict + +import click + +from kicad_lib_manager.utils.constants import TEMPLATE_METADATA, TEMPLATES_DIR +from kicad_lib_manager.utils.template.models import ( + DiscoveredTemplate, + MetadataError, + discovered_template, + read_template_metadata, +) + + +class ConfiguredLibrary(TypedDict): + """The fields of a configured library that template discovery reads.""" + + name: str + path: str + + +class LibrarySource(Protocol): + """Lists the configured libraries in priority order, as ``Config`` does.""" + + def get_libraries(self) -> Sequence[ConfiguredLibrary]: + """Return every configured library.""" + ... + + +def list_templates_in_directory(directory: Path) -> list[DiscoveredTemplate]: + """List the templates in the ``templates`` directory of a library. + + A template is a subdirectory with a non-empty ``metadata.yaml``; the + library's directory name is recorded as its source library. Metadata that + cannot be read is reported on stderr and the template is skipped. + """ + templates_dir = directory / TEMPLATES_DIR + if not templates_dir.is_dir(): + return [] + templates: list[DiscoveredTemplate] = [] + for template_dir in templates_dir.iterdir(): + if not (template_dir / TEMPLATE_METADATA).is_file(): + continue + try: + metadata = read_template_metadata(template_dir) + except MetadataError as error: + click.echo(str(error), err=True) + continue + if metadata is not None: + templates.append(discovered_template(metadata, template_dir, directory)) + return templates + + +def find_all_templates( + config: LibrarySource, library: str | None = None +) -> dict[str, DiscoveredTemplate]: + """Find the templates of every configured library, keyed by template name. + + Args: + config: The configured libraries, searched in their configured order. + library: Only search the library with this name (case-insensitive). + + Returns: + The templates by name. Templates without a name are left out. When + two libraries define the same name, the first library's template is + kept and the clash is reported on stderr. + """ + wanted = None if library is None else library.lower() + templates: dict[str, DiscoveredTemplate] = {} + for configured in config.get_libraries(): + if wanted is not None and configured["name"].lower() != wanted: + continue + library_path = configured["path"] + if not library_path or not Path(library_path).exists(): + continue + for template in list_templates_in_directory(Path(library_path)): + name = template.get("name") + if name is None or not name: + continue + if name in templates: + _report_duplicate(name, templates[name], template) + continue + template["source_library"] = configured["name"] + template["library_path"] = library_path + templates[name] = template + return templates + + +def _report_duplicate( + name: str, kept: DiscoveredTemplate, ignored: DiscoveredTemplate +) -> None: + click.echo(f"Warning: Duplicate template name '{name}' found.", err=True) + click.echo(f" Using: {kept['path']}", err=True) + click.echo(f" Ignoring: {ignored['path']}", err=True) diff --git a/kicad_lib_manager/utils/template/hooks.py b/kicad_lib_manager/utils/template/hooks.py new file mode 100644 index 0000000..cfdb857 --- /dev/null +++ b/kicad_lib_manager/utils/template/hooks.py @@ -0,0 +1,116 @@ +"""The post-creation hook that a template can run after ``template create``.""" + +import importlib.util +from pathlib import Path +from typing import Protocol, TypedDict, runtime_checkable + +import click + +from kicad_lib_manager.utils.template.constants import POST_CREATE_HOOK +from kicad_lib_manager.utils.template.models import RenderVariables, TemplateMetadata + +HOOK_MODULE_NAME = "hook" +HOOK_FUNCTION = "post_create" + +# The starting point written into every new template's hooks directory. Raw, so +# the escapes in its commented examples reach the file as written. +HOOK_SCRIPT = r'''""" +Post-creation hook for template. +This script runs after the template has been used to create a new project. +""" + + +def post_create(context): + """ + Hook that runs after project creation. + + Args: + context: Dict containing: + - project_dir: Path to the created project + - variables: Dict of all template variables and their values + - template: Template metadata + + Returns: + None + """ + # Uncomment and modify as needed + # import subprocess + # from pathlib import Path + + # Example: Initialize git repository + # subprocess.run(["git", "init"], cwd=context["project_dir"], check=True) + + # Example: Create README.md if it doesn't exist + # readme_path = Path(context["project_dir"]) / "README.md" + # if not readme_path.exists(): + # readme_path.write_text( + # f"# {context['variables']['project_name']}\n\n" + # "Created with KiCad Library Manager\n" + # ) + + # Print message to user + print(f"Project {context['variables']['project_name']} created successfully!") + print(f"Location: {context['project_dir']}") +''' + + +class HookContext(TypedDict): + """The argument passed to a template's ``post_create`` function. + + ``template`` keeps the keys of ``metadata.yaml`` that KiLM does not know, + so a hook can read its own settings from there. + """ + + project_dir: str + variables: RenderVariables + template: TemplateMetadata + + +@runtime_checkable +class PostCreateHook(Protocol): + """The ``post_create`` function of a hook script.""" + + def __call__(self, context: HookContext, /) -> object: + """Act on the newly created project.""" + ... + + +def create_hook_script(hooks_dir: Path) -> None: + """Write the starting post-creation hook into a template's hooks directory.""" + (hooks_dir / POST_CREATE_HOOK).write_text(HOOK_SCRIPT, encoding="utf-8") + + +def run_post_create_hook( + hook_script: Path, + project_dir: Path, + variables: RenderVariables, + template_metadata: TemplateMetadata, +) -> None: + """Import ``hook_script`` and call its ``post_create`` function. + + A missing script does nothing; a script that cannot be loaded or has no + ``post_create`` function is reported on stderr. Whatever the hook raises + propagates to the caller. + """ + if not hook_script.exists(): + return + spec = importlib.util.spec_from_file_location(HOOK_MODULE_NAME, hook_script) + if spec is None or spec.loader is None: + click.echo(f"Error: Could not load hook script {hook_script}", err=True) + return + hook = importlib.util.module_from_spec(spec) + spec.loader.exec_module(hook) + post_create: object = getattr(hook, HOOK_FUNCTION, None) + if not isinstance(post_create, PostCreateHook): + click.echo( + f"Warning: Hook script {hook_script} does not have a post_create function", + err=True, + ) + return + post_create( + HookContext( + project_dir=str(project_dir), + variables=variables, + template=template_metadata, + ) + ) diff --git a/kicad_lib_manager/utils/template/kicad_files.py b/kicad_lib_manager/utils/template/kicad_files.py new file mode 100644 index 0000000..4f96b09 --- /dev/null +++ b/kicad_lib_manager/utils/template/kicad_files.py @@ -0,0 +1,160 @@ +"""Turning a source project's files into Jinja templates for ``template make``. + +Each function writes ``.jinja2``. When the source cannot be read or +the template cannot be written, the error is reported on stderr and the file +is copied unchanged instead. +""" + +import json +import re +import shutil +from collections.abc import Callable, Mapping +from pathlib import Path + +import click + +from kicad_lib_manager.utils.template.constants import ( + JINJA_SUFFIX, + KICAD_PROJECT_EXT, + KICAD_SCHEMATIC_EXT, + PROJECT_FILENAME_VARIABLE, + PROJECT_NAME_VARIABLES, +) +from kicad_lib_manager.utils.template.models import TemplateVariable + +PROJECT_FILENAME_TEMPLATE = f"{{{{ {PROJECT_FILENAME_VARIABLE} }}}}" +SCHEMATIC_PROJECT_PATTERN = re.compile(r'\(project\s+"([^"]+)"') +PCB_SHEETFILE_PATTERN = re.compile(r'\(sheetfile\s+"([^"]+)"') + +# Wraps the variable list so that rendering a project removes it. +MARKDOWN_HEADER_START = ( + "{# This file is a Jinja template. The section below will be removed when" + " creating a project. #}\n\n{% if false %}\n# Template Variables\n\n" + "This template supports the following variables:\n\n" +) +MARKDOWN_HEADER_END = ( + "\nUse these variables in your content with `{{ variable_name }}`.\n{% endif %}\n" +) + + +def process_markdown_file( + source_file: Path, + target_file: Path, + variables: Mapping[str, TemplateVariable], +) -> None: + """Template a Markdown file, prefixing a removable list of the variables.""" + listing = "".join( + f'- `{{ {name} }}`: {info["description"]} (default: "{info["default"]}")\n' + for name, info in variables.items() + ) + + def add_header(content: str) -> str: + return MARKDOWN_HEADER_START + listing + MARKDOWN_HEADER_END + content + + _write_template(source_file, target_file, "Markdown file", add_header) + + +def process_kicad_project_file( + source_file: Path, + target_file: Path, + variables: Mapping[str, TemplateVariable], +) -> None: + """Template a ``.kicad_pro`` file's ``meta.filename`` as the project filename. + + The filename is only templated when ``variables`` has a project name + variable. A file that is not JSON is copied unchanged with a warning. + """ + rename = any(name.lower() in PROJECT_NAME_VARIABLES for name in variables) + + def template_filename(content: str) -> str | None: + try: + project: object = json.loads(content) + except json.JSONDecodeError: + click.echo( + f"Warning: Could not parse {source_file.name} as JSON." + " Copying without changes." + ) + return None + if rename: + project = _with_templated_filename(project) + return json.dumps(project, indent=2) + + _write_template(source_file, target_file, "KiCad project file", template_filename) + + +def process_kicad_schematic_file(source_file: Path, target_file: Path) -> None: + """Template the ``(project "...")`` references of a ``.kicad_sch`` file.""" + + def template_project(content: str) -> str: + click.echo( + " Templated schematic project name references with" + f" '{PROJECT_FILENAME_TEMPLATE}'" + ) + return SCHEMATIC_PROJECT_PATTERN.sub( + f'(project "{PROJECT_FILENAME_TEMPLATE}"', content + ) + + _write_template(source_file, target_file, "KiCad schematic file", template_project) + + +def process_kicad_pcb_file(source_file: Path, target_file: Path) -> None: + """Template the ``(sheetfile "...")`` references to the main schematic. + + A reference counts as the main schematic when its file name contains the + PCB file's stem. + """ + schematic = f"{PROJECT_FILENAME_TEMPLATE}{KICAD_SCHEMATIC_EXT}" + + def template_sheet(match: re.Match[str]) -> str: + if source_file.stem in match.group(1): + return f'(sheetfile "{schematic}"' + return match.group(0) + + def template_sheets(content: str) -> str: + click.echo(f" Templated PCB sheet references with '{schematic}'") + return PCB_SHEETFILE_PATTERN.sub(template_sheet, content) + + _write_template(source_file, target_file, "KiCad PCB file", template_sheets) + + +def _with_templated_filename(project: object) -> object: + """Return ``project`` with ``meta.filename`` templated, if it has one.""" + if not isinstance(project, dict): + return project + fields = {str(key): value for key, value in project.items()} + meta_value = fields.get("meta") + if not isinstance(meta_value, dict): + return project + meta = {str(key): value for key, value in meta_value.items()} + if "filename" not in meta: + return project + templated = f"{PROJECT_FILENAME_TEMPLATE}{KICAD_PROJECT_EXT}" + click.echo(f" Templated project filename: '{meta['filename']}' → '{templated}'") + meta["filename"] = templated + fields["meta"] = meta + return fields + + +def _write_template( + source_file: Path, + target_file: Path, + kind: str, + transform: Callable[[str], str | None], +) -> None: + """Write ``transform(source)`` to ``.jinja2``. + + When ``transform`` returns None, or reading or writing fails, the source + is copied to ``target_file`` unchanged. + """ + target_template = target_file.with_name(target_file.name + JINJA_SUFFIX) + try: + content = transform(source_file.read_text(encoding="utf-8")) + if content is not None: + target_template.write_text(content, encoding="utf-8") + except (OSError, UnicodeError) as error: + click.echo(f"Error processing {kind} {source_file.name}: {error}", err=True) + content = None + if content is None: + shutil.copy2(source_file, target_file) + return + click.echo(f"Processed {kind}: {source_file.name} → {target_template.name}") diff --git a/kicad_lib_manager/utils/template/models.py b/kicad_lib_manager/utils/template/models.py new file mode 100644 index 0000000..9a086f1 --- /dev/null +++ b/kicad_lib_manager/utils/template/models.py @@ -0,0 +1,214 @@ +"""Typed template metadata and its validation from ``metadata.yaml``.""" + +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"yaml"} + +from collections.abc import Collection, Mapping +from pathlib import Path +from typing import TypeAlias, TypedDict, TypeVar, cast + +import yaml + +from kicad_lib_manager.utils.constants import TEMPLATE_METADATA + +_Validated = TypeVar("_Validated", bound=Mapping[str, object]) + +# Values a template is rendered with, keyed by variable name. +RenderVariables: TypeAlias = Mapping[str, str | int | bool] + + +class TemplateVariable(TypedDict): + """A variable that a template asks for when a project is created.""" + + description: str + default: str + + +class TemplateDependencies(TypedDict): + """Libraries a template recommends.""" + + recommended: list[str] + + +class TemplateMetadata(TypedDict, total=False): + """The contents of a template's ``metadata.yaml``. + + Every key is optional in a file that a user wrote; ``template make`` writes + name, description, use_case, version, and variables. + """ + + name: str + description: str + use_case: str + version: str + variables: dict[str, TemplateVariable] + extends: str + dependencies: TemplateDependencies + + +class DiscoveredTemplate(TemplateMetadata, total=True): + """Template metadata plus where the template was found.""" + + path: str + source_library: str + library_path: str + + +# The keys each TypedDict declares (inherited ones included). Other keys in +# metadata.yaml are custom and kept as loaded. +_TEMPLATE_KEYS = ( + DiscoveredTemplate.__required_keys__ | DiscoveredTemplate.__optional_keys__ +) +_VARIABLE_KEYS = TemplateVariable.__required_keys__ | TemplateVariable.__optional_keys__ +_DEPENDENCY_KEYS = ( + TemplateDependencies.__required_keys__ | TemplateDependencies.__optional_keys__ +) + + +def discovered_template( + metadata: TemplateMetadata, template_dir: Path, library_dir: Path +) -> DiscoveredTemplate: + """Return ``metadata`` with where it was found: ``template_dir`` in ``library_dir``. + + The library's directory name is recorded as the source library. Builds a + new mapping instead of unpacking ``metadata`` as keyword arguments, + because its custom keys need not be strings. + """ + # TypedDicts allow extra keys; the declared ones are set below. + template = cast("DiscoveredTemplate", dict(metadata)) + template["path"] = str(template_dir) + template["source_library"] = library_dir.name + template["library_path"] = str(library_dir) + return template + + +class MetadataError(Exception): + """A ``metadata.yaml`` file could not be read or parsed.""" + + +def read_template_metadata(template_dir: Path) -> TemplateMetadata | None: + """Read the metadata of the template in ``template_dir``. + + Returns None when the file holds no mapping or an empty one. + + Raises: + MetadataError: The file is missing, unreadable, or not valid YAML. + """ + metadata_file = template_dir / TEMPLATE_METADATA + try: + with metadata_file.open(encoding="utf-8") as stream: + raw: object = yaml.safe_load(stream) + except (OSError, UnicodeDecodeError, yaml.YAMLError) as error: + message = f"Error reading template metadata from {metadata_file}: {error}" + raise MetadataError(message) from error + return parse_template_metadata(raw) + + +def parse_template_metadata(raw: object) -> TemplateMetadata | None: + """Validate loaded YAML as template metadata. + + Known keys are kept with their declared types: scalars become strings, + variable names become text (templates and ``--set`` refer to them by + name), a variable without a description or default gets the ones + ``template create`` shows, and malformed values are dropped. Other keys, + also inside a variable or ``dependencies``, are kept as loaded, keys + included (``7`` and ``"7"`` stay distinct), for hooks and ``template list + --json``; the keys that discovery adds (``path`` and the library) are not + taken from the file. Returns None unless ``raw`` is a non-empty mapping. + """ + fields = _mapping(raw) + if fields is None or not fields: + return None + metadata = TemplateMetadata() + if (name := _text(fields.get("name"))) is not None: + metadata["name"] = name + if (description := _text(fields.get("description"))) is not None: + metadata["description"] = description + if (use_case := _text(fields.get("use_case"))) is not None: + metadata["use_case"] = use_case + if (version := _text(fields.get("version"))) is not None: + metadata["version"] = version + if (extends := _text(fields.get("extends"))) is not None: + metadata["extends"] = extends + if (variables := _mapping(fields.get("variables"))) is not None: + metadata["variables"] = _variables(variables) + if (dependencies := _mapping(fields.get("dependencies"))) is not None: + metadata["dependencies"] = _dependencies(dependencies) + return _with_custom_keys(metadata, fields, _TEMPLATE_KEYS) + + +def write_template_metadata(directory: Path, metadata: TemplateMetadata) -> None: + """Write ``metadata`` to the ``metadata.yaml`` file in ``directory``. + + Raises: + OSError: The file cannot be written. + """ + metadata_file = directory / TEMPLATE_METADATA + with metadata_file.open("w", encoding="utf-8") as stream: + yaml.dump(dict(metadata), stream, default_flow_style=False) + + +def default_description(name: str) -> str: + """Return the description of a variable whose author gave none.""" + return f"Value for {name}" + + +def _mapping(value: object) -> dict[object, object] | None: + """Return a YAML mapping as loaded, or None for other values.""" + if not isinstance(value, Mapping): + return None + return dict(cast("Mapping[object, object]", value)) + + +def _text(value: object) -> str | None: + """Return a YAML scalar as text, or None for a missing or nested value.""" + if value is None or isinstance(value, (Mapping, list)): + return None + return str(value) + + +def _with_custom_keys( + validated: _Validated, + fields: Mapping[object, object], + declared: Collection[str], +) -> _Validated: + """Return ``validated`` followed by the ``fields`` that are not ``declared``. + + The result keeps the type of ``validated``, whose declared keys were + checked. The custom keys are kept as loaded, so they need not be strings. + """ + merged = { + **validated, + **{key: value for key, value in fields.items() if key not in declared}, + } + # A TypedDict cannot declare non-string keys, so the type checker calls + # this cast impossible; at runtime a TypedDict is a dict and takes any key. + return cast( # ty: ignore[disjoint-cast] -- custom keys need not be strings + "_Validated", merged + ) + + +def _variables(raw: dict[object, object]) -> dict[str, TemplateVariable]: + variables: dict[str, TemplateVariable] = {} + for key, value in raw.items(): + name = str(key) + fields = _mapping(value) or {} + description = _text(fields.get("description")) + default = _text(fields.get("default")) + variable = TemplateVariable( + description=default_description(name) + if description is None + else description, + default="" if default is None else default, + ) + variables[name] = _with_custom_keys(variable, fields, _VARIABLE_KEYS) + return variables + + +def _dependencies(raw: dict[object, object]) -> TemplateDependencies: + recommended = raw.get("recommended") + names = recommended if isinstance(recommended, list) else [] + dependencies = TemplateDependencies( + recommended=[text for item in names if (text := _text(item)) is not None] + ) + return _with_custom_keys(dependencies, raw, _DEPENDENCY_KEYS) diff --git a/kicad_lib_manager/utils/template/project.py b/kicad_lib_manager/utils/template/project.py new file mode 100644 index 0000000..d92f7e6 --- /dev/null +++ b/kicad_lib_manager/utils/template/project.py @@ -0,0 +1,146 @@ +"""Creating a project from a template, for ``template create``.""" + +import os +import traceback +from pathlib import Path +from typing import NamedTuple + +import click + +from kicad_lib_manager.utils.constants import HOOKS_DIR, TEMPLATE_CONTENT_DIR +from kicad_lib_manager.utils.template.constants import ( + BINARY_EXTENSIONS, + JINJA_SUFFIX, + POST_CREATE_HOOK, + SYSTEM_FILES_TO_IGNORE, +) +from kicad_lib_manager.utils.template.hooks import run_post_create_hook +from kicad_lib_manager.utils.template.models import ( + DiscoveredTemplate, + RenderVariables, +) +from kicad_lib_manager.utils.template.rendering import ( + render_filename, + render_template_file, +) + + +class PlannedFile(NamedTuple): + """A template file and the project file it renders to.""" + + source: Path + target: Path + is_binary: bool + + +class ProjectPlan(NamedTuple): + """The directories and files a template creates, in template walk order.""" + + directories: list[Path] + files: list[PlannedFile] + + +def create_project_from_template( + template: DiscoveredTemplate, + project_dir: Path, + variables: RenderVariables, + *, + dry_run: bool = False, + skip_hooks: bool = False, +) -> bool: + """Render a template into ``project_dir`` and run its post-creation hook. + + A non-empty ``project_dir`` is merged into, with a warning. A dry run only + prints the directories and files that would be created. + + Returns: + False when the template has no content directory, otherwise True. + Files that fail to render and hooks that fail are reported on stderr. + """ + template_dir = Path(template["path"]) + content_dir = template_dir / TEMPLATE_CONTENT_DIR + if not content_dir.is_dir(): + click.echo( + f"Error: Template content directory {content_dir} does not exist", + err=True, + ) + return False + if not dry_run and project_dir.is_dir() and any(project_dir.iterdir()): + click.echo(f"Warning: Target directory {project_dir} is not empty", err=True) + extends = template.get("extends") + if extends is not None and extends: + click.echo("Warning: Template inheritance not fully implemented yet", err=True) + + plan = plan_project(content_dir, project_dir, variables) + if dry_run: + _print_plan(plan) + return True + + for directory in plan.directories: + directory.mkdir(parents=True, exist_ok=True) + for planned in plan.files: + if not render_template_file( + planned.source, planned.target, variables, is_binary=planned.is_binary + ): + click.echo(f"Warning: Failed to create {planned.target}", err=True) + if not skip_hooks: + _run_hook( + template_dir / HOOKS_DIR / POST_CREATE_HOOK, + project_dir, + variables, + template, + ) + return True + + +def plan_project( + content_dir: Path, project_dir: Path, variables: RenderVariables +) -> ProjectPlan: + """Map a template's content directory onto ``project_dir``. + + Directory and file names are rendered with ``variables``, the ``.jinja2`` + suffix is dropped, and system files such as ``.DS_Store`` are skipped. + """ + plan = ProjectPlan(directories=[], files=[]) + for root, _directories, names in os.walk(content_dir): + relative = Path(root).relative_to(content_dir) + target_dir = project_dir + if relative.parts: + target_dir = project_dir / render_filename(str(relative), variables) + plan.directories.append(target_dir) + for name in names: + if name in SYSTEM_FILES_TO_IGNORE: + continue + target_name = name.removesuffix(JINJA_SUFFIX) + plan.files.append( + PlannedFile( + source=Path(root) / name, + target=target_dir / render_filename(target_name, variables), + is_binary=target_name.lower().endswith(tuple(BINARY_EXTENSIONS)), + ) + ) + return plan + + +def _print_plan(plan: ProjectPlan) -> None: + """Show what a dry run would create.""" + for directory in plan.directories: + click.echo(f"Would create directory: {directory}") + click.echo() + click.echo("The following files would be created:") + for planned in plan.files: + click.echo(f" {planned.target}") + + +def _run_hook( + hook_script: Path, + project_dir: Path, + variables: RenderVariables, + template: DiscoveredTemplate, +) -> None: + """Run the template's post-creation hook, reporting any failure.""" + try: + run_post_create_hook(hook_script, project_dir, variables, template) + except Exception as error: # noqa: BLE001 -- the hook is template code and may raise anything + click.echo(f"Error running post-creation hook: {error}", err=True) + traceback.print_exc() diff --git a/kicad_lib_manager/utils/template/rendering.py b/kicad_lib_manager/utils/template/rendering.py new file mode 100644 index 0000000..85b8c64 --- /dev/null +++ b/kicad_lib_manager/utils/template/rendering.py @@ -0,0 +1,192 @@ +"""Rendering of template file names and file contents with template variables. + +File names use the Windows-compatible ``%{variable.transform}`` placeholders +that ``render_filename_custom`` expands; the Jinja2 ``{{ variable }}`` syntax +still works in file names and is the syntax of content files ending in +``.jinja2``. Rendering problems are reported on stderr and leave the text +unrendered rather than failing the whole project. +""" + +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"jinja2"} + +import re +import shutil +from pathlib import Path + +import click +import jinja2 + +from kicad_lib_manager.utils.template.constants import ( + FILENAME_VAR_PATTERN, + JINJA_SUFFIX, +) +from kicad_lib_manager.utils.template.models import RenderVariables + +REPLACE_PREFIX = "replace(" + + +def contains_template_syntax(text: str) -> bool: + """Whether ``text`` holds a ``%{...}`` or ``{{ ... }}`` placeholder.""" + return bool(FILENAME_VAR_PATTERN.search(text)) or ("{{" in text and "}}" in text) + + +def render_template_string(template_str: str, variables: RenderVariables) -> str: + """Render ``template_str``, preferring the ``%{...}`` syntax over Jinja2. + + Undefined Jinja2 variables render as empty text. On a Jinja2 error the + warning goes to stderr and ``template_str`` is returned unchanged. + """ + if FILENAME_VAR_PATTERN.search(template_str): + return render_filename_custom(template_str, variables) + try: + return _environment().from_string(template_str).render(**variables) + except jinja2.TemplateError as error: + click.echo(f"Warning: Failed to render template: {error}", err=True) + return template_str + + +def render_filename_custom(filename: str, variables: RenderVariables) -> str: + """Expand the Windows-compatible ``%{variable}`` placeholders in ``filename``. + + A placeholder may chain transformations after the variable name: + + - ``%{project_name.lower}`` and ``%{project_name.upper}`` change case. + - ``%{project_name.replace(' ', '-')}`` replaces text. + - ``%{project_name.replace(' ', '_').lower}`` applies both in order. + + A placeholder naming an unknown variable stays as written; unknown or + malformed transformations are skipped. Both are reported on stderr. + """ + return FILENAME_VAR_PATTERN.sub( + lambda match: _expand_placeholder(match, variables), filename + ) + + +def render_filename(filename: str, variables: RenderVariables) -> str: + """Render a file or directory name from a template. + + ``%{...}`` placeholders take precedence; otherwise ``{{ ... }}`` is rendered + with Jinja2, where an undefined variable is an error. On an error the + warning goes to stderr and ``filename`` is returned unchanged. + """ + if FILENAME_VAR_PATTERN.search(filename): + return render_filename_custom(filename, variables) + if "{{" not in filename or "}}" not in filename: + return filename + try: + return _environment(strict=True).from_string(filename).render(**variables) + except jinja2.TemplateError as error: + click.echo(f"Warning: Failed to render filename {filename}: {error}", err=True) + return filename + + +def render_template_file( + source_file: Path, + target_file: Path, + variables: RenderVariables, + *, + is_binary: bool = False, +) -> bool: + """Write ``source_file`` to ``target_file``, rendering it if it is a template. + + Binary files are copied. Text files whose name ends in ``.jinja2`` are + rendered with Jinja2, where an undefined variable is an error, and written + without that suffix; other text files are copied. When reading or rendering + fails, the source is copied unchanged. + + Returns: + False when Jinja2 rejects the template (the source is still copied) + or when nothing could be written; True when the file was rendered or + copied, including a template copied because it could not be read or + its rendering could not be written. + """ + target_file.parent.mkdir(parents=True, exist_ok=True) + if is_binary: + return _copy(source_file, target_file, "binary file") + if not source_file.name.endswith(JINJA_SUFFIX): + return _copy(source_file, target_file, "file") + try: + content = source_file.read_text(encoding="utf-8") + environment = _environment( + strict=True, loader=jinja2.FileSystemLoader(source_file.parent) + ) + rendered = environment.from_string(content).render(**variables) + except jinja2.TemplateError as error: + click.echo(f"Error rendering template {source_file.name}: {error}", err=True) + _copy(source_file, target_file, "file") + return False + except (OSError, UnicodeDecodeError) as error: + click.echo(f"Error processing file {source_file}: {error}", err=True) + return _copy(source_file, target_file, "file") + rendered_file = Path(str(target_file).removesuffix(JINJA_SUFFIX)) + try: + rendered_file.write_text(rendered, encoding="utf-8") + except OSError as error: + click.echo(f"Error processing file {source_file}: {error}", err=True) + return _copy(source_file, target_file, "file") + return True + + +def _environment( + *, strict: bool = False, loader: jinja2.BaseLoader | None = None +) -> jinja2.Environment: + """Return a Jinja2 environment for plain-text output. + + Autoescaping stays off: the output is KiCad files, text, and file names, so + HTML escaping would turn a value such as ``R&D`` into ``R&D``. + """ + return jinja2.Environment( + loader=loader, + undefined=jinja2.StrictUndefined if strict else jinja2.Undefined, + autoescape=False, # noqa: S701 -- output is KiCad files and text, never HTML + ) + + +def _copy(source_file: Path, target_file: Path, kind: str) -> bool: + """Copy ``source_file`` unchanged, reporting a failure on stderr.""" + try: + shutil.copy2(source_file, target_file) + except OSError as error: + click.echo(f"Error copying {kind} {source_file}: {error}", err=True) + return False + return True + + +def _expand_placeholder(match: re.Match[str], variables: RenderVariables) -> str: + """Return the value of one ``%{variable.transform...}`` placeholder.""" + name, *transformations = match.group(1).split(".") + name = name.strip() + if name not in variables: + click.echo( + f"Warning: Variable '{name}' not found in template variables", err=True + ) + return match.group(0) + value = str(variables[name]) + for transformation in transformations: + value = _transform(value, transformation.strip()) + return value + + +def _transform(value: str, transformation: str) -> str: + if transformation == "lower": + return value.lower() + if transformation == "upper": + return value.upper() + if transformation.startswith(REPLACE_PREFIX): + return _replace(value, transformation) + click.echo(f"Warning: Unknown transformation '{transformation}'", err=True) + return value + + +def _replace(value: str, transformation: str) -> str: + """Apply ``replace(old, new)``, where both arguments may be quoted.""" + arguments = transformation[len(REPLACE_PREFIX) : -1] + if arguments.count(",") != 1: + click.echo( + f"Warning: Invalid replace transformation format: {transformation}", + err=True, + ) + return value + old, new = (argument.strip().strip("'\"") for argument in arguments.split(",")) + return value.replace(old, new) diff --git a/kicad_lib_manager/utils/template/selection.py b/kicad_lib_manager/utils/template/selection.py new file mode 100644 index 0000000..8191c9c --- /dev/null +++ b/kicad_lib_manager/utils/template/selection.py @@ -0,0 +1,252 @@ +"""Choosing the files of a source project that a new template copies. + +``plan_template_files`` is the one place that decides what ``template make`` +copies, so the dry-run preview and the copy agree. The variable scan uses the +same rules without the template directory and ``--exclude`` patterns. +""" + +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"pathspec"} + +import os +import re +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path, PurePath +from typing import TYPE_CHECKING + +import click +import pathspec + +if TYPE_CHECKING: + from pathspec import PathSpec + +from kicad_lib_manager.utils.template.constants import ( + ALWAYS_IGNORED_PATTERNS, + DEFAULT_VARIABLE_PATTERNS, + EXCLUDED_DIRECTORIES, + KICAD_PCB_EXT, + KICAD_PRL_EXT, + KICAD_PROJECT_EXT, + KICAD_SCHEMATIC_EXT, + MAX_SCANNED_FILE_BYTES, +) + +GITIGNORE = ".gitignore" +GITIGNORE_SYNTAX = "gitwildmatch" + + +class MainFileKind(Enum): + """The KiCad files of a project that are renamed after ``project_filename``.""" + + PROJECT = KICAD_PROJECT_EXT + SCHEMATIC = KICAD_SCHEMATIC_EXT + PCB = KICAD_PCB_EXT + + @property + def label(self) -> str: + """How messages name this kind of file.""" + return "PCB" if self is MainFileKind.PCB else self.name.lower() + + +@dataclass(frozen=True) +class Exclusion: + """A path left out of a template and the message that explains why. + + ``path`` is relative to the source in POSIX form; directories end in ``/``. + """ + + path: str + message: str + + +@dataclass +class TemplatePlan: + """What a template made from a source project contains. + + Paths are relative to the source directory, in ``os.walk`` order. + ``main_files`` maps each kind to the first such file in the source + directory or one level below it. + """ + + directories: list[PurePath] = field(default_factory=list) + files: list[PurePath] = field(default_factory=list) + excluded: list[Exclusion] = field(default_factory=list) + main_files: dict[MainFileKind, PurePath] = field(default_factory=dict) + + def main_kind(self, relative: PurePath) -> MainFileKind | None: + """Return the kind of main KiCad file ``relative`` is, if it is one.""" + return next( + (kind for kind, path in self.main_files.items() if path == relative), + None, + ) + + +def get_gitignore_spec(directory: Path) -> pathspec.PathSpec | None: + """Return the ``.gitignore`` patterns of ``directory`` plus common ignores. + + Returns None when there is no ``.gitignore``. A file that cannot be read is + reported on stderr and also gives None. + """ + gitignore_file = directory / GITIGNORE + if not gitignore_file.exists(): + return None + try: + lines = gitignore_file.read_text(encoding="utf-8").splitlines(keepends=True) + except (OSError, UnicodeDecodeError) as error: + click.echo(f"Warning: Error reading .gitignore file: {error}", err=True) + return None + lines += [ + f"{pattern}\n" + for pattern in ALWAYS_IGNORED_PATTERNS + if not any(pattern in line for line in lines) + ] + return pathspec.PathSpec.from_lines(GITIGNORE_SYNTAX, lines) + + +def plan_template_files( + source_directory: Path, + *, + template_directory: Path | None = None, + gitignore_spec: pathspec.PathSpec | None = None, + additional_excludes: list[str] | None = None, +) -> TemplatePlan: + """Decide which files and directories of a source project a template copies. + + Left out are the directories in ``EXCLUDED_DIRECTORIES`` at any depth, the + template's own directory when it lies inside the source, paths matched by + ``gitignore_spec`` or the gitignore-style ``additional_excludes``, and + ``.kicad_prl`` preference files. + """ + rules = _ExclusionRules( + source=source_directory, + template_root=template_directory, + gitignore=gitignore_spec, + additional=None + if additional_excludes is None + else pathspec.PathSpec.from_lines(GITIGNORE_SYNTAX, additional_excludes), + ) + plan = TemplatePlan() + for root, directories, files in os.walk(source_directory): + relative_root = PurePath(os.path.relpath(root, source_directory)) + if relative_root.parts: + plan.directories.append(relative_root) + kept = [] + for name in directories: + reason = rules.directory_exclusion(relative_root / name) + if reason is None: + kept.append(name) + else: + plan.excluded.append(reason) + directories[:] = kept + for name in files: + reason = rules.file_exclusion(relative_root / name) + if reason is None: + plan.files.append(relative_root / name) + else: + plan.excluded.append(reason) + plan.main_files = _find_main_files(plan.files) + return plan + + +def find_potential_variables( + directory: Path, patterns: list[str] | None = None +) -> dict[str, list[str]]: + """Scan the files a template of ``directory`` would copy for variable values. + + Args: + directory: The source project. + patterns: Regexes whose first group, or whole match when they have no + group, is a candidate value; None means DEFAULT_VARIABLE_PATTERNS. + + Returns: + Distinct values in order of discovery, keyed by a variable name made + from the lower-cased value. Files over 1 MiB or not UTF-8 are skipped. + """ + chosen = DEFAULT_VARIABLE_PATTERNS if patterns is None else patterns + compiled = [re.compile(pattern) for pattern in chosen] + plan = plan_template_files(directory, gitignore_spec=get_gitignore_spec(directory)) + variables: dict[str, list[str]] = {} + for relative in plan.files: + content = _read_small_text(directory / relative) + if content is None: + continue + for pattern in compiled: + for match in pattern.finditer(content): + value = match.group(1) if pattern.groups else match.group(0) + if not value: + continue + values = variables.setdefault(_variable_name(value), []) + if value not in values: + values.append(value) + return variables + + +@dataclass(frozen=True) +class _ExclusionRules: + """The exclusion checks of ``plan_template_files``, in their order.""" + + source: Path + template_root: Path | None + # Quoted names that exist only for type checkers: dataclass processing + # resolves field annotations, which would load the lazily imported + # pathspec when this module is imported. + gitignore: "PathSpec | None" + additional: "PathSpec | None" + + def directory_exclusion(self, relative: PurePath) -> Exclusion | None: + path = f"{relative.as_posix()}/" + if relative.name in EXCLUDED_DIRECTORIES: + return Exclusion(path, f"Excluding common directory: {relative.name}") + if self.template_root is not None and ( + (self.source / relative) + .resolve() + .is_relative_to(self.template_root.resolve()) + ): + message = f"Preventing recursive template copy: {relative.as_posix()}" + return Exclusion(path, message) + if self.gitignore is not None and self.gitignore.match_file(path): + return Exclusion(path, f"Excluding directory from gitignore: {path}") + if self.additional is not None and self.additional.match_file(path): + message = f"Excluding directory from additional patterns: {path}" + return Exclusion(path, message) + return None + + def file_exclusion(self, relative: PurePath) -> Exclusion | None: + path = relative.as_posix() + if self.gitignore is not None and self.gitignore.match_file(path): + return Exclusion(path, f"Excluding file from gitignore: {path}") + if self.additional is not None and self.additional.match_file(path): + return Exclusion(path, f"Excluding file from additional patterns: {path}") + if relative.name.lower().endswith(KICAD_PRL_EXT): + message = f"Excluding KiCad project preferences file: {path}" + return Exclusion(path, message) + return None + + +def _find_main_files(files: list[PurePath]) -> dict[MainFileKind, PurePath]: + """Pick the first file of each main kind at most one directory deep.""" + main_files: dict[MainFileKind, PurePath] = {} + for relative in files: + if len(relative.parent.parts) > 1: + continue + name = relative.name.lower() + for kind in MainFileKind: + if name.endswith(kind.value): + main_files.setdefault(kind, relative) + break + return main_files + + +def _read_small_text(path: Path) -> str | None: + """Return the text of a UTF-8 file of at most 1 MiB, otherwise None.""" + try: + if path.stat().st_size > MAX_SCANNED_FILE_BYTES: + return None + return path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return None + + +def _variable_name(value: str) -> str: + return re.sub(r"[^a-zA-Z0-9_]", "_", value.lower()) diff --git a/pyproject.toml b/pyproject.toml index a2f27f6..ff61c1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)", "Natural Language :: English", "Environment :: Console", @@ -49,6 +50,7 @@ dependencies = [ "jinja2>=3.1.6", "questionary>=1.10.0", "requests>=2.32.4", + "typing-extensions>=4.4.0", ] [dependency-groups] @@ -93,32 +95,69 @@ addopts = [ # Matches pytest's pythonpath so the tests of .github/scripts resolve. extra-paths = [".github/scripts"] +[tool.ty.rules] +# Every rule, including those off by default, fails the check. +all = "error" + +[tool.ty.analysis] +# Only rule-specific `# ty: ignore[rule]` comments suppress diagnostics. +respect-type-ignore-comments = false +# Sound narrowing: no literal narrowing from `==`, no assumptions about +# unspecialized generics in isinstance checks. +strict-equality-semantics = true +strict-generic-narrowing = true + [tool.ruff] line-length = 88 [tool.ruff.lint] -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade - "ARG", # flake8-unused-arguments - "SIM", # flake8-simplify - "TC", # flake8-type-checking - "PTH", # flake8-use-pathlib - "ERA", # eradicate +select = ["ALL"] +# Preview complexity limits, opted into one by one so other preview rules and +# preview behavior stay off. +preview = true +explicit-preview-rules = true +extend-select = [ + "PLR0904", # too-many-public-methods + "PLR0914", # too-many-locals + "PLR0916", # too-many-boolean-expressions + "PLR1702", # too-many-nested-blocks ] ignore = [ - "E501", # line too long (handled by ruff format) - "B008", # do not perform function calls in argument defaults - "TC003", # type checking is not required + # Conflicts with ruff format, which owns trailing commas. + "COM812", + # Licensing is stated once in LICENSE and pyproject.toml; a per-file + # copyright header would repeat it in every module. + "CPY001", ] [tool.ruff.lint.per-file-ignores] -"tests/**/*" = ["ARG", "S101"] +# pytest reports failures through plain asserts, and expected values in tests +# are the specification, not magic numbers. +"tests/**" = ["S101", "PLR2004"] +# Standalone scripts that release.yml runs by path; tests import them as +# top-level modules through pytest's pythonpath, not as a package. +".github/scripts/*.py" = ["INP001"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.pylint] +# Ruff's defaults, stated so they are visible and only ever tightened. +max-args = 5 +max-positional-args = 5 +max-branches = 12 +max-returns = 6 +max-statements = 50 +max-locals = 15 +max-nested-blocks = 5 +max-bool-expr = 5 +max-public-methods = 20 [tool.coverage.run] source = ["kicad_lib_manager"] @@ -128,8 +167,8 @@ omit = [ ] [tool.coverage.report] -# Measured 45.5% when coverage became opt-in; raise it as tests are added. -fail_under = 45 +# Measured 76.7% after the max-strictness refactor; raise it as tests are added. +fail_under = 76 show_missing = true exclude_lines = [ "pragma: no cover", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fdf6261 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""The kilm test suite; a package so test module names cannot collide.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2ca21b1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +"""Suite-wide fixtures that keep tests away from the user's machine.""" + +from pathlib import Path + +import pytest +import requests + + +def _no_network(*_args: object, **_kwargs: object) -> requests.Response: + """Stand in for requests.get; tests that need a response patch it.""" + message = "tests must not use the network" + raise requests.ConnectionError(message) + + +@pytest.fixture(autouse=True) +def home( + tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch +) -> Path: + """Give each test an empty home, returned for tests that use it, and no network. + + Every CLI invocation runs the update check, which reads the KiLM config, + writes the version cache, and asks PyPI. Without this the suite would + depend on and change the real home directory and the network. + """ + home = tmp_path_factory.mktemp("home") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.setattr(requests, "get", _no_network) + return home diff --git a/tests/fault_injection.py b/tests/fault_injection.py new file mode 100644 index 0000000..d649690 --- /dev/null +++ b/tests/fault_injection.py @@ -0,0 +1,36 @@ +"""Fault injection for tests of writers that must not lose data.""" + +import errno +import io +from collections.abc import Callable +from typing import ParamSpec, TypeVar + +import pytest + +_P = ParamSpec("_P") +_T = TypeVar("_T") + + +def fail_writes(monkeypatch: pytest.MonkeyPatch) -> None: + """Make every file opened for writing fail right after it is opened. + + Opening with ``w`` truncates the file first, so a writer that rewrites its + target in place loses the content, while an atomic writer loses only its + temporary file. The failure reads "No space left on device". + """ + monkeypatch.setattr(io, "open", _failing_after_open(io.open)) + + +def _failing_after_open(real_open: Callable[_P, _T]) -> Callable[_P, _T]: + """Wrap ``real_open`` so that files opened for writing raise ENOSPC.""" + + def failing_open(*args: _P.args, **kwargs: _P.kwargs) -> _T: + handle = real_open(*args, **kwargs) + if isinstance(handle, (io.TextIOWrapper, io.BufferedWriter)) and ( + "w" in handle.mode + ): + handle.close() + raise OSError(errno.ENOSPC, "No space left on device") + return handle + + return failing_open diff --git a/tests/import_zip/__init__.py b/tests/import_zip/__init__.py index e69de29..bcbca3a 100644 --- a/tests/import_zip/__init__.py +++ b/tests/import_zip/__init__.py @@ -0,0 +1 @@ +"""Tests of the import command.""" diff --git a/tests/import_zip/conftest.py b/tests/import_zip/conftest.py index 3088984..679d07d 100644 --- a/tests/import_zip/conftest.py +++ b/tests/import_zip/conftest.py @@ -5,7 +5,7 @@ import pytest -from .helpers import SAMPLE_SYM_LIB +from tests.import_zip.helpers import SAMPLE_SYM_LIB @pytest.fixture(autouse=True) diff --git a/tests/import_zip/helpers.py b/tests/import_zip/helpers.py index 2452d49..1b94ecb 100644 --- a/tests/import_zip/helpers.py +++ b/tests/import_zip/helpers.py @@ -7,6 +7,8 @@ from click.testing import Result from typer.testing import CliRunner +from kicad_lib_manager.main import app + runner = CliRunner() SAMPLE_SYM_LIB = """\ @@ -99,12 +101,6 @@ def snapmagic_zip(tmp_path: Path, part: str) -> Path: def run_import(*args: str) -> Result: """Run 'kilm import' with args, without kicad-cli.""" - # Imported here, not at module level: conftest.py imports this module while - # pytest is not capturing output, and importing the app then creates every - # module-level Rich Console against the real stdout. On a terminal they - # would pick a color system and put ANSI codes into all CliRunner output. - from kicad_lib_manager.main import app - with patch( "kicad_lib_manager.commands.import_zip.command.detect_kicad_cli", return_value=None, diff --git a/tests/import_zip/test_archive.py b/tests/import_zip/test_archive.py index 1b3f0e3..ebbcfbb 100644 --- a/tests/import_zip/test_archive.py +++ b/tests/import_zip/test_archive.py @@ -4,14 +4,12 @@ import zipfile from collections.abc import Callable from pathlib import Path -from unittest.mock import MagicMock import pytest from kicad_lib_manager.commands.import_zip.archive import extract_part_files -from kicad_lib_manager.commands.import_zip.errors import ImportFailure - -from .helpers import run_import, samacsys_zip, summary, zip_with +from kicad_lib_manager.commands.import_zip.errors import ImportZipError +from tests.import_zip.helpers import run_import, samacsys_zip, summary, zip_with def _set_header_field(path: Path, local: int, central: int, value: int) -> None: @@ -67,16 +65,18 @@ def _invalid_utf8_name(path: Path) -> Path: def test_zip_slip_entry_rejects_the_archive(tmp_path: Path) -> None: + """An entry that would land outside the extraction dir rejects the whole ZIP.""" zip_path = zip_with(tmp_path, "evil", {"../../outside.kicad_mod": "x"}) dest = tmp_path / "extract" dest.mkdir() - with pytest.raises(ImportFailure, match="unsafe ZIP entry"): + with pytest.raises(ImportZipError, match="unsafe ZIP entry"): extract_part_files(zip_path, dest) assert not (tmp_path / "outside.kicad_mod").exists() def test_only_part_files_are_extracted(tmp_path: Path) -> None: + """Only symbol, footprint, and 3D model members are extracted.""" zip_path = samacsys_zip(tmp_path, "P") with zipfile.ZipFile(zip_path, "a") as zf: zf.writestr("P/readme.html", "") @@ -93,12 +93,13 @@ def test_only_part_files_are_extracted(tmp_path: Path) -> None: @pytest.mark.parametrize( "damage", [_encrypted, _imploded, _corrupt_lzma, _invalid_utf8_name] ) +@pytest.mark.usefixtures("mock_config") def test_unreadable_members_fail_only_their_zip( tmp_path: Path, library_tree: Path, - mock_config: MagicMock, damage: Callable[[Path], Path], ) -> None: + """An encrypted or unsupported member fails its ZIP; the other ZIPs import.""" (tmp_path / "bad").mkdir() bad = damage(samacsys_zip(tmp_path / "bad", "BadPart")) good = samacsys_zip(tmp_path, "GoodPart") @@ -113,9 +114,9 @@ def test_unreadable_members_fail_only_their_zip( assert "BadPart" not in sym_text -def test_macos_metadata_entries_are_ignored( - tmp_path: Path, library_tree: Path, mock_config: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_config") +def test_macos_metadata_entries_are_ignored(tmp_path: Path, library_tree: Path) -> None: + """__MACOSX resource forks with part extensions are not imported.""" zip_path = samacsys_zip(tmp_path, "MacPart") with zipfile.ZipFile(zip_path, "a") as zf: for name in ("._MacPart.kicad_mod", "._MacPart.kicad_sym", "._MacPart.stp"): @@ -132,9 +133,11 @@ def test_macos_metadata_entries_are_ignored( assert not list((library_tree / "footprints").rglob("._*")) +@pytest.mark.usefixtures("mock_config") def test_missing_or_invalid_zip_fails_the_run_after_importing_the_rest( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """Missing and corrupt ZIPs exit 1 only after the valid ones are imported.""" not_zip = tmp_path / "notes.zip" not_zip.write_text("not a zip") good = samacsys_zip(tmp_path, "GoodPart") diff --git a/tests/import_zip/test_cli.py b/tests/import_zip/test_cli.py index 585d4b0..b36400a 100644 --- a/tests/import_zip/test_cli.py +++ b/tests/import_zip/test_cli.py @@ -4,7 +4,9 @@ from pathlib import Path from unittest.mock import MagicMock -from .helpers import ( +import pytest + +from tests.import_zip.helpers import ( SAMPLE_SYM_LIB, run_import, samacsys_zip, @@ -19,9 +21,11 @@ def _sym_text(library_tree: Path, name: str = "SAMPLELIB") -> str: return (library_tree / "symbols" / f"{name}.kicad_sym").read_text(encoding="utf-8") +@pytest.mark.usefixtures("mock_config") def test_samacsys_zip_adds_symbol_footprint_and_model( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """A SamacSys ZIP adds its symbol, footprint, and model, linked together.""" result = run_import(str(samacsys_zip(tmp_path, "TestPart"))) assert result.exit_code == 0, result.output @@ -32,9 +36,9 @@ def test_samacsys_zip_adds_symbol_footprint_and_model( assert (library_tree / "SAMPLELIB.3dshapes" / "TestPart.stp").exists() -def test_ultralibrarian_zip_adds_part( - tmp_path: Path, library_tree: Path, mock_config: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_config") +def test_ultralibrarian_zip_adds_part(tmp_path: Path, library_tree: Path) -> None: + """An UltraLibrarian export with a nested .pretty dir imports.""" result = run_import(str(ultralibrarian_zip(tmp_path, "UlPart"))) assert result.exit_code == 0, result.output @@ -46,9 +50,9 @@ def test_ultralibrarian_zip_adds_part( ).exists() -def test_snapmagic_zip_adds_part( - tmp_path: Path, library_tree: Path, mock_config: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_config") +def test_snapmagic_zip_adds_part(tmp_path: Path, library_tree: Path) -> None: + """A SnapMagic ZIP with its files at the root imports.""" result = run_import(str(snapmagic_zip(tmp_path, "SnapPart"))) assert result.exit_code == 0, result.output @@ -59,9 +63,9 @@ def test_snapmagic_zip_adds_part( assert (library_tree / "SAMPLELIB.3dshapes" / "SnapPart.step").exists() -def test_dry_run_changes_nothing( - tmp_path: Path, library_tree: Path, mock_config: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_config") +def test_dry_run_changes_nothing(tmp_path: Path, library_tree: Path) -> None: + """--dry-run reports the parts but writes no file.""" before = sorted(p.relative_to(library_tree) for p in library_tree.rglob("*")) result = run_import("--dry-run", str(samacsys_zip(tmp_path, "DryPart"))) @@ -74,9 +78,9 @@ def test_dry_run_changes_nothing( assert _sym_text(library_tree) == SAMPLE_SYM_LIB -def test_existing_parts_are_skipped( - tmp_path: Path, library_tree: Path, mock_config: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_config") +def test_existing_parts_are_skipped(tmp_path: Path, library_tree: Path) -> None: + """A symbol already in the library is reported as existing, not added again.""" result = run_import(str(samacsys_zip(tmp_path, "ExistingPart"))) assert result.exit_code == 0, result.output @@ -84,9 +88,9 @@ def test_existing_parts_are_skipped( assert _sym_text(library_tree).count('(symbol "ExistingPart"') == 1 -def test_several_libraries_require_a_choice( - tmp_path: Path, category_tree: Path -) -> None: +@pytest.mark.usefixtures("category_tree") +def test_several_libraries_require_a_choice(tmp_path: Path) -> None: + """With several symbol libraries and no --symbol-lib the command stops.""" result = run_import(str(samacsys_zip(tmp_path, "AmbPart"))) assert result.exit_code == 1 @@ -94,7 +98,9 @@ def test_several_libraries_require_a_choice( assert "CAT_IC" in result.output -def test_unknown_library_lists_available(tmp_path: Path, category_tree: Path) -> None: +@pytest.mark.usefixtures("category_tree") +def test_unknown_library_lists_available(tmp_path: Path) -> None: + """An unknown --symbol-lib name lists the available libraries.""" zip_path = samacsys_zip(tmp_path, "XPart") result = run_import( @@ -108,6 +114,7 @@ def test_unknown_library_lists_available(tmp_path: Path, category_tree: Path) -> def test_import_into_chosen_category_libraries( tmp_path: Path, category_tree: Path ) -> None: + """--symbol-lib and --footprint-lib pick the libraries in a split library.""" zip_path = zip_with( tmp_path, "VendChip", @@ -127,9 +134,11 @@ def test_import_into_chosen_category_libraries( assert "VendChip" not in _sym_text(category_tree) +@pytest.mark.usefixtures("mock_config") def test_missing_kicad_cli_path_stops_before_changes( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """A --kicad-cli path that does not exist stops the run before any write.""" zip_path = samacsys_zip(tmp_path, "CliPart") result = run_import( @@ -144,6 +153,7 @@ def test_missing_kicad_cli_path_stops_before_changes( def test_default_library_is_the_current_library( tmp_path: Path, library_tree: Path, mock_config: MagicMock ) -> None: + """Without --library the current KiLM library is the target.""" # Like setup and template, import targets the library made current by # 'kilm init' / 'kilm config set-default', not merely the first one. other = tmp_path / "other" @@ -160,9 +170,11 @@ def test_default_library_is_the_current_library( assert "CurPart" not in _sym_text(library_tree) +@pytest.mark.usefixtures("library_tree", "mock_config") def test_dry_run_matches_real_run_for_parts_repeated_across_zips( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, ) -> None: + """A dry run skips parts repeated across ZIPs exactly as a real run does.""" (tmp_path / "a").mkdir() (tmp_path / "b").mkdir() a = samacsys_zip(tmp_path / "a", "Twin") @@ -176,9 +188,11 @@ def test_dry_run_matches_real_run_for_parts_repeated_across_zips( assert summary(dry.output) == summary(real.output) +@pytest.mark.usefixtures("mock_config") def test_undecodable_footprint_fails_dry_run_and_writes_nothing( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """A footprint that is not UTF-8 fails its ZIP in a dry run and a real run.""" zip_path = samacsys_zip(tmp_path, "BadPart") with zipfile.ZipFile(zip_path, "a") as zf: zf.writestr("BadPart/KiCad/BadFP.kicad_mod", b'(footprint "BadFP" "\xff")\n') diff --git a/tests/import_zip/test_formats.py b/tests/import_zip/test_formats.py index 6570e2f..477e558 100644 --- a/tests/import_zip/test_formats.py +++ b/tests/import_zip/test_formats.py @@ -2,13 +2,19 @@ import subprocess from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch +import pytest from click.testing import Result from kicad_lib_manager.main import app - -from .helpers import SAMPLE_SYM_LIB, run_import, runner, samacsys_zip, zip_with +from tests.import_zip.helpers import ( + SAMPLE_SYM_LIB, + run_import, + runner, + samacsys_zip, + zip_with, +) KICAD8_SYM = 20231120 KICAD8_FP = 20240108 @@ -90,9 +96,11 @@ def _kicad8_library(library_tree: Path) -> tuple[Path, Path]: return sym_lib, fp_dir +@pytest.mark.usefixtures("mock_config") def test_upgrade_never_makes_library_format_newer( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """kicad-cli upgrades that would exceed the library's format are dropped.""" # A library saved by KiCad 8 must stay loadable by KiCad 8 even when the # importing machine has a newer kicad-cli. sym_lib, fp_dir = _kicad8_library(library_tree) @@ -109,9 +117,11 @@ def test_upgrade_never_makes_library_format_newer( assert "SAMPLELIB.3dshapes/OldPart.stp" in fp_text +@pytest.mark.usefixtures("mock_config") def test_upgrade_applies_when_library_format_allows( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """kicad-cli upgrades apply when the library already uses that format.""" sym_lib = library_tree / "symbols" / "SAMPLELIB.kicad_sym" sym_lib.write_text( SAMPLE_SYM_LIB.replace( @@ -128,9 +138,9 @@ def test_upgrade_applies_when_library_format_allows( assert f"(version {NEW_FORMAT})" in fp.read_text(encoding="utf-8") -def test_newer_vendor_symbols_are_rejected( - tmp_path: Path, library_tree: Path, mock_config: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_config") +def test_newer_vendor_symbols_are_rejected(tmp_path: Path, library_tree: Path) -> None: + """Symbols newer than the target library's format fail their ZIP.""" # Merged under the library's 20231120 header, KiCad 8 would reject the # newer embedded_fonts token and with it the whole library. sym_lib, _ = _kicad8_library(library_tree) @@ -156,15 +166,19 @@ def test_newer_vendor_symbols_are_rejected( assert not (library_tree / "SAMPLELIB.3dshapes" / "NewPart.stp").exists() +@pytest.mark.usefixtures("mock_config") def test_newer_vendor_footprints_are_rejected( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """Footprints newer than the footprint library's format fail their ZIP.""" _, fp_dir = _kicad8_library(library_tree) zip_path = zip_with( tmp_path, "newer", { - "N/KiCad/NewFP.kicad_mod": f'(footprint "NewFP"\n\t(version {NEW_FORMAT})\n)\n' + "N/KiCad/NewFP.kicad_mod": ( + f'(footprint "NewFP"\n\t(version {NEW_FORMAT})\n)\n' + ) }, ) diff --git a/tests/import_zip/test_models.py b/tests/import_zip/test_models.py index e4bee46..7349796 100644 --- a/tests/import_zip/test_models.py +++ b/tests/import_zip/test_models.py @@ -1,13 +1,11 @@ """3D model placement and the model references written into footprints.""" from pathlib import Path -from unittest.mock import MagicMock import pytest from kicad_lib_manager.commands.import_zip.importer import ModelRef, fix_3d_path - -from .helpers import run_import, summary, zip_with +from tests.import_zip.helpers import run_import, summary, zip_with LIB_MODELS = { "somepart.stp": ModelRef("${LIB}/SAMPLELIB.3dshapes", "somepart.stp"), @@ -40,6 +38,7 @@ def _footprint(library_tree: Path, name: str) -> str: ], ) def test_fix_3d_path_points_known_models_at_the_library(raw: str) -> None: + """Model references to known model files point into the library.""" # Regression: a misordered alternation matched a compound extension's own # prefix ("step" inside "step.gz") and left the rest dangling. result = fix_3d_path(raw, LIB_MODELS) @@ -50,14 +49,17 @@ def test_fix_3d_path_points_known_models_at_the_library(raw: str) -> None: def test_fix_3d_path_keeps_references_to_other_models() -> None: + """Model references to unknown files, such as stock models, are unchanged.""" text = '(model "${KICAD9_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl"' assert fix_3d_path(text, LIB_MODELS) == text +@pytest.mark.usefixtures("mock_config") def test_footprint_reference_to_a_stock_model_is_kept( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """An imported footprint keeps its reference to a KiCad stock model.""" ref = '(model "${KICAD9_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl"' zip_path = zip_with( tmp_path, "C", {"C.kicad_mod": f'(footprint "C"\n\t{ref}\n\t)\n)\n'} @@ -69,9 +71,11 @@ def test_footprint_reference_to_a_stock_model_is_kept( assert ref in _footprint(library_tree, "C.kicad_mod") +@pytest.mark.usefixtures("mock_config") def test_fallback_models_use_library_env_var( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """Without a 3D library, model paths use kilm.yaml's env_var.""" (library_tree / "kilm.yaml").write_text( "name: mylib\nenv_var: KICAD_LIB_MYLIB\n", encoding="utf-8" ) @@ -79,7 +83,9 @@ def test_fallback_models_use_library_env_var( tmp_path, "M1", { - "M1/KiCad/M1.kicad_mod": '(footprint "M1"\n\t(model "C:/x/M1.stp"\n\t)\n)\n', + "M1/KiCad/M1.kicad_mod": ( + '(footprint "M1"\n\t(model "C:/x/M1.stp"\n\t)\n)\n' + ), "M1/3D/M1.stp": "STEP", }, ) @@ -91,9 +97,11 @@ def test_fallback_models_use_library_env_var( assert '(model "${KICAD_LIB_MYLIB}/SAMPLELIB.3dshapes/M1.stp"' in fp +@pytest.mark.usefixtures("mock_config") def test_fallback_models_without_env_var_use_absolute_path( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """Without any env_var, model paths are absolute.""" zip_path = zip_with( tmp_path, "M2", @@ -110,9 +118,11 @@ def test_fallback_models_without_env_var_use_absolute_path( assert (library_tree / "SAMPLELIB.3dshapes").resolve().as_posix() in fp +@pytest.mark.usefixtures("mock_config") def test_split_library_fallback_models_dir_named_after_library( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """A split library's new models dir is named after the KiLM library.""" (library_tree / "symbols" / "OTHER.kicad_sym").write_text("(kicad_symbol_lib\n)\n") zip_path = zip_with(tmp_path, "M3", {"M3/3D/M3.stp": "STEP"}) @@ -123,9 +133,11 @@ def test_split_library_fallback_models_dir_named_after_library( assert "3D model paths will be absolute to this machine" in result.output +@pytest.mark.usefixtures("mock_config") def test_models_dir_prefers_library_named_dir( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """Of several *.3dshapes dirs, the one named after the library is used.""" (library_tree / "AAA.3dshapes").mkdir() (library_tree / "SAMPLELIB.3dshapes").mkdir() zip_path = zip_with(tmp_path, "M4", {"M4/3D/M4.stp": "STEP"}) @@ -137,9 +149,11 @@ def test_models_dir_prefers_library_named_dir( assert not (library_tree / "AAA.3dshapes" / "M4.stp").exists() +@pytest.mark.usefixtures("mock_config") def test_model_in_other_models_dir_is_reused( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """A model already in another *.3dshapes dir is referenced there, not copied.""" (library_tree / "Aaa.3dshapes").mkdir() (library_tree / "Bbb.3dshapes").mkdir() (library_tree / "Bbb.3dshapes" / "Part.step").write_text("STEP") @@ -148,7 +162,9 @@ def test_model_in_other_models_dir_is_reused( tmp_path, "Reuse", { - "R/KiCad/RFP.kicad_mod": '(footprint "RFP"\n\t(model "x/Part.step"\n\t)\n)\n', + "R/KiCad/RFP.kicad_mod": ( + '(footprint "RFP"\n\t(model "x/Part.step"\n\t)\n)\n' + ), "R/3D/Part.step": "STEP", }, ) @@ -168,6 +184,7 @@ def test_model_in_other_models_dir_is_reused( def test_registered_3d_library_receives_models( tmp_path: Path, category_tree: Path ) -> None: + """A 3D library registered inside the library receives the models.""" zip_path = zip_with( tmp_path, "CatPart", @@ -191,6 +208,7 @@ def test_registered_3d_library_receives_models( def test_absolute_paths_from_other_model_dirs_are_warned( tmp_path: Path, category_tree: Path ) -> None: + """Reusing a model from a dir without an env_var warns about absolute paths.""" (category_tree / "Old.3dshapes").mkdir() (category_tree / "Old.3dshapes" / "Legacy.step").write_text("STEP") zip_path = zip_with(tmp_path, "W", {"W/3D/New.step": "STEP"}) @@ -204,6 +222,7 @@ def test_absolute_paths_from_other_model_dirs_are_warned( def test_hidden_files_in_model_dirs_are_ignored( tmp_path: Path, category_tree: Path ) -> None: + """Hidden files in model dirs are not treated as models.""" # A .DS_Store in an extra dir without a library env var must not count as # a model (it would trigger the absolute-path warning on its own). (category_tree / "Old.3dshapes").mkdir() @@ -216,9 +235,9 @@ def test_hidden_files_in_model_dirs_are_ignored( assert "absolute to this machine" not in result.output -def test_dry_run_reports_model_added_earlier_as_skip( - tmp_path: Path, library_tree: Path, mock_config: MagicMock -) -> None: +@pytest.mark.usefixtures("library_tree", "mock_config") +def test_dry_run_reports_model_added_earlier_as_skip(tmp_path: Path) -> None: + """A dry run reports a model an earlier ZIP of the run added as existing.""" a = zip_with(tmp_path, "a", {"A/3D/Shared.step": "STEP"}) b = zip_with(tmp_path, "b", {"B/3D/Shared.step": "STEP"}) @@ -230,9 +249,11 @@ def test_dry_run_reports_model_added_earlier_as_skip( assert "reuse existing" not in result.output +@pytest.mark.usefixtures("mock_config") def test_file_names_differing_only_in_case_are_one_file( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """Files whose names differ only in case are imported once.""" # A library must hold the same files on case-sensitive and # case-insensitive filesystems, and dry runs must count what real runs add. a = zip_with( @@ -260,9 +281,11 @@ def test_file_names_differing_only_in_case_are_one_file( assert [p.name for p in fp_dir.iterdir()] == ["Foo.kicad_mod"] +@pytest.mark.usefixtures("mock_config") def test_members_differing_only_in_case_import_the_first_everywhere( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """Of ZIP members differing only in case, the first is imported.""" # On a case-insensitive filesystem the second member used to overwrite the # first during extraction, so platforms imported different geometry. zip_path = zip_with( @@ -281,9 +304,11 @@ def test_members_differing_only_in_case_import_the_first_everywhere( assert '(descr "first")' in _footprint(library_tree, "Foo.kicad_mod") +@pytest.mark.usefixtures("mock_config") def test_footprint_extension_is_written_in_lowercase( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """Footprints are written with the .kicad_mod suffix KiCad lists.""" # KiCad lists only *.kicad_mod, so Part.KICAD_MOD would be missing from # the library on case-sensitive filesystems. zip_path = zip_with( @@ -309,9 +334,11 @@ def test_footprint_extension_is_written_in_lowercase( @pytest.mark.parametrize("kept_from", ["library", "earlier ZIP"]) +@pytest.mark.usefixtures("mock_config") def test_symbols_reference_the_kept_spelling_of_a_skipped_footprint( - tmp_path: Path, library_tree: Path, mock_config: MagicMock, kept_from: str + tmp_path: Path, library_tree: Path, kept_from: str ) -> None: + """Symbols reference an existing footprint by its name in the library.""" # On case-sensitive filesystems "SAMPLELIB:foo" would not find Foo.kicad_mod. fp_dir = library_tree / "footprints" / "SAMPLELIB.pretty" zips = [ diff --git a/tests/import_zip/test_symbols.py b/tests/import_zip/test_symbols.py index 1023496..debf884 100644 --- a/tests/import_zip/test_symbols.py +++ b/tests/import_zip/test_symbols.py @@ -2,23 +2,24 @@ import pytest -from kicad_lib_manager.commands.import_zip.errors import ImportFailure +from kicad_lib_manager.commands.import_zip.errors import ImportZipError from kicad_lib_manager.commands.import_zip.symbols import ( extract_symbol_blocks, fix_footprint_ref, merge_symbols, symbol_name, ) - -from .helpers import INCOMING_SYM, SAMPLE_SYM_LIB +from tests.import_zip.helpers import INCOMING_SYM, SAMPLE_SYM_LIB def test_extract_symbol_blocks() -> None: + """Top-level symbol blocks are found whatever the indentation.""" blocks = extract_symbol_blocks(INCOMING_SYM) assert [symbol_name(b) for b in blocks] == ["NewPart", "ExistingPart"] def test_extract_symbol_blocks_ignores_parens_in_strings() -> None: + """Parentheses inside quoted strings do not end a symbol block.""" text = ( "(kicad_symbol_lib\n" '\t(symbol "PartA"\n' @@ -30,6 +31,7 @@ def test_extract_symbol_blocks_ignores_parens_in_strings() -> None: def test_fix_footprint_ref_adds_prefix() -> None: + """A bare footprint name gets the default footprint library.""" block = '\t(property "Footprint" "BareFootprint")' assert '"Footprint" "SAMPLELIB:BareFootprint"' in fix_footprint_ref( block, "SAMPLELIB" @@ -37,17 +39,20 @@ def test_fix_footprint_ref_adds_prefix() -> None: def test_fix_footprint_ref_keeps_existing_prefix() -> None: + """A footprint name with a library prefix is kept.""" block = '\t(property "Footprint" "OTHER:Footprint")' assert '"Footprint" "OTHER:Footprint"' in fix_footprint_ref(block, "SAMPLELIB") def test_fix_footprint_ref_prefers_imported_footprint() -> None: + """A footprint imported with the symbol wins over the vendor prefix.""" block = '\t(property "Footprint" "SamacSys_Parts:VENDFP")' result = fix_footprint_ref(block, "SAMPLELIB", {"VENDFP": "CAT_QFN:VENDFP"}) assert '"Footprint" "CAT_QFN:VENDFP"' in result def test_merge_symbols_adds_new_skips_existing() -> None: + """Symbols the library lacks are added; present ones are skipped.""" merge = merge_symbols(INCOMING_SYM, SAMPLE_SYM_LIB, "SAMPLELIB") assert merge.added == ["NewPart"] @@ -58,6 +63,7 @@ def test_merge_symbols_adds_new_skips_existing() -> None: def test_merge_symbols_adds_repeated_name_once() -> None: + """A symbol repeated in the source is added once.""" source = '(kicad_symbol_lib\n\t(symbol "Twice"\n\t)\n\t(symbol "Twice"\n\t)\n)\n' merge = merge_symbols(source, SAMPLE_SYM_LIB, "SAMPLELIB") @@ -67,6 +73,7 @@ def test_merge_symbols_adds_repeated_name_once() -> None: def test_merge_symbols_treats_known_names_as_present() -> None: + """Names in known are skipped like symbols already in the library.""" merge = merge_symbols(INCOMING_SYM, SAMPLE_SYM_LIB, "SAMPLELIB", known={"NewPart"}) assert merge.added == [] @@ -74,5 +81,6 @@ def test_merge_symbols_treats_known_names_as_present() -> None: def test_merge_symbols_rejects_a_non_library_target() -> None: - with pytest.raises(ImportFailure, match="not a KiCad symbol library"): + """A target that is not a .kicad_sym library raises ImportZipError.""" + with pytest.raises(ImportZipError, match="not a KiCad symbol library"): merge_symbols(INCOMING_SYM, "", "SAMPLELIB") diff --git a/tests/import_zip/test_writes.py b/tests/import_zip/test_writes.py index e8c5cdc..b26ad9c 100644 --- a/tests/import_zip/test_writes.py +++ b/tests/import_zip/test_writes.py @@ -5,13 +5,12 @@ import stat import sys from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from kicad_lib_manager.utils.file_ops import write_atomically - -from .helpers import ( +from tests.import_zip.helpers import ( SAMPLE_SYM_LIB, run_import, samacsys_zip, @@ -24,9 +23,11 @@ def _no_space(_fd: int) -> None: raise OSError(errno.ENOSPC, os.strerror(errno.ENOSPC)) +@pytest.mark.usefixtures("mock_config") def test_failed_symbol_write_leaves_library_intact( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """A failed symbol write leaves the library unchanged and no temp file behind.""" sym_lib = library_tree / "symbols" / "SAMPLELIB.kicad_sym" zip_path = zip_with( tmp_path, @@ -45,16 +46,17 @@ def test_failed_symbol_write_leaves_library_intact( assert not list(sym_lib.parent.glob(".*.tmp")) +@pytest.mark.usefixtures("mock_config") def test_files_written_before_a_failed_write_are_kept_for_later_zips( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: - # A later ZIP must skip what a failed ZIP already wrote, not replace it. - from kicad_lib_manager.utils.file_ops import write_atomically as real_write + """Files written before a failed write stay and later ZIPs skip them.""" + # A later ZIP must skip what a failed ZIP already wrote, not replace it. def fail_symbol_writes(path: Path, data: bytes) -> None: if path.suffix == ".kicad_sym": _no_space(0) - real_write(path, data) + write_atomically(path, data) a = zip_with( tmp_path, @@ -89,9 +91,11 @@ def fail_symbol_writes(path: Path, data: bytes) -> None: assert model.read_text(encoding="utf-8") == "STEP from a" +@pytest.mark.usefixtures("mock_config") def test_symbol_library_is_backed_up_once_per_run( - tmp_path: Path, library_tree: Path, mock_config: MagicMock + tmp_path: Path, library_tree: Path ) -> None: + """The symbol library is backed up before its first change in a run only.""" sym_lib = library_tree / "symbols" / "SAMPLELIB.kicad_sym" (tmp_path / "a").mkdir() @@ -107,9 +111,11 @@ def test_symbol_library_is_backed_up_once_per_run( @pytest.mark.parametrize("newline", ["\n", "\r\n"]) +@pytest.mark.usefixtures("mock_config") def test_written_files_keep_library_line_endings( - tmp_path: Path, library_tree: Path, mock_config: MagicMock, newline: str + tmp_path: Path, library_tree: Path, newline: str ) -> None: + """Merged symbols take the library's line endings; other bytes are kept.""" # Rewriting every line ending of a git-tracked library (Windows text-mode # writes, or CRLF vendor files) would bury the import in a whole-file diff. sym_lib = library_tree / "symbols" / "SAMPLELIB.kicad_sym" @@ -129,6 +135,7 @@ def test_written_files_keep_library_line_endings( @pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits") def test_write_atomically_keeps_permissions(tmp_path: Path) -> None: + """write_atomically keeps the replaced file's permission bits.""" path = tmp_path / "lib.kicad_sym" path.write_text("old") path.chmod(0o664) diff --git a/tests/test_add_3d_command.py b/tests/test_add_3d_command.py new file mode 100644 index 0000000..e871077 --- /dev/null +++ b/tests/test_add_3d_command.py @@ -0,0 +1,70 @@ +"""Tests for the ``kilm add-3d`` command.""" + +import json +from pathlib import Path +from typing import cast + +from typer.testing import CliRunner + +from kicad_lib_manager.main import app +from kicad_lib_manager.utils.constants import CLOUD_METADATA_FILE + + +def _models_dir(tmp_path: Path, metadata: dict[str, object]) -> Path: + """Return a directory with one model and the given cloud metadata.""" + directory = tmp_path / "models" + directory.mkdir() + (directory / "part.step").write_text("", encoding="utf-8") + (directory / CLOUD_METADATA_FILE).write_text(json.dumps(metadata), encoding="utf-8") + return directory + + +def _add_3d(directory: Path, *options: str) -> dict[str, object]: + """Run add-3d on directory and return the metadata it leaves behind.""" + result = CliRunner().invoke( + app, ["add-3d", "--directory", str(directory), *options] + ) + assert result.exit_code == 0, result.output + text = (directory / CLOUD_METADATA_FILE).read_text(encoding="utf-8") + loaded: object = json.loads(text) + assert isinstance(loaded, dict) + return cast("dict[str, object]", loaded) + + +def test_add_3d_keeps_fields_the_typed_metadata_cannot_hold(tmp_path: Path) -> None: + """Rewriting the metadata keeps mistyped known fields as they were.""" + directory = _models_dir( + tmp_path, + {"name": "models", "version": 2, "description": None, "maintainer": "Ada"}, + ) + + metadata = _add_3d(directory) + + assert metadata["version"] == 2 + assert metadata["description"] is None + assert metadata["maintainer"] == "Ada" + assert metadata["model_count"] == 1 + + +def test_add_3d_overrides_replace_mistyped_fields(tmp_path: Path) -> None: + """Given options win over kept fields; --no-env-var clears the variable.""" + directory = _models_dir( + tmp_path, + {"name": "models", "version": 2, "description": None, "env_var": 5}, + ) + + metadata = _add_3d(directory, "--description", "Parts", "--no-env-var") + + assert metadata["description"] == "Parts" + assert metadata["env_var"] is None + assert metadata["version"] == 2 + + +def test_add_3d_force_starts_fresh(tmp_path: Path) -> None: + """--force replaces the metadata, mistyped fields included.""" + directory = _models_dir(tmp_path, {"name": "old", "version": 2}) + + metadata = _add_3d(directory, "--force") + + assert metadata["name"] == "models" + assert metadata["version"] == "1.0.0" diff --git a/tests/test_add_hook_command.py b/tests/test_add_hook_command.py new file mode 100644 index 0000000..a612d12 --- /dev/null +++ b/tests/test_add_hook_command.py @@ -0,0 +1,67 @@ +"""Tests for ``kilm add-hook`` in temporary repositories.""" + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from kicad_lib_manager.main import app + +GIT = shutil.which("git") + +pytestmark = pytest.mark.skipif(GIT is None, reason="git is not installed") + + +@pytest.fixture +def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Return an empty git repository isolated from the user's git config.""" + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(tmp_path / ".gitconfig")) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + repo = tmp_path / "repo" + repo.mkdir() + assert GIT is not None + subprocess.run( # noqa: S603 -- fixed git arguments built by the test + [GIT, "init", "--quiet"], cwd=repo, check=True + ) + return repo + + +def test_add_hook_installs_an_executable_post_merge_hook(repo: Path) -> None: + """The hook runs ``kilm sync`` and uses LF line endings on every platform.""" + result = CliRunner().invoke(app, ["add-hook", "--directory", str(repo)]) + + assert result.exit_code == 0, result.output + hook = repo / ".git" / "hooks" / "post-merge" + content = hook.read_bytes() + assert b"kilm sync" in content + assert b"\r\n" not in content + if os.name != "nt": + assert hook.stat().st_mode & 0o111 + + +def test_add_hook_merges_into_an_existing_hook(repo: Path) -> None: + """Without ``--force`` an existing hook keeps its own commands.""" + hook = repo / ".git" / "hooks" / "post-merge" + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text("#!/bin/sh\necho mine\n", encoding="utf-8") + + result = CliRunner().invoke( + app, ["add-hook", "--directory", str(repo)], input="y\n" + ) + + assert result.exit_code == 0, result.output + content = hook.read_text(encoding="utf-8") + assert "echo mine" in content + assert "kilm sync" in content + assert len(list(hook.parent.glob("post-merge.backup.*"))) == 1 + + +def test_add_hook_outside_a_repository_reports_one_error(tmp_path: Path) -> None: + """A directory that is not a repository fails with a single error line.""" + result = CliRunner().invoke(app, ["add-hook", "--directory", str(tmp_path)]) + + assert result.exit_code == 1 + assert result.output.count("Error") == 1, result.output diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..f668f49 --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,72 @@ +"""Tests for timestamped file backups.""" + +from datetime import datetime, timezone, tzinfo +from pathlib import Path + +import pytest + +from kicad_lib_manager.utils import backup +from kicad_lib_manager.utils.backup import create_backup + +FROZEN_NOW = datetime(2026, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc) + + +class _FrozenClock: + """Stands in for the datetime class: now() always reads FROZEN_NOW.""" + + @staticmethod + def now(tz: tzinfo | None = None) -> datetime: + """FROZEN_NOW in the requested zone, local time when tz is None.""" + return FROZEN_NOW.astimezone(tz) + + +@pytest.fixture +def frozen_clock(monkeypatch: pytest.MonkeyPatch) -> None: + """Make every backup see the same instant.""" + monkeypatch.setattr(backup, "datetime", _FrozenClock) + + +def test_backup_copies_the_file(tmp_path: Path) -> None: + """The backup sits next to the file and holds its content.""" + original = tmp_path / "sym-lib-table" + original.write_text("v1", encoding="utf-8") + + backup_path = create_backup(original) + + assert backup_path.parent == tmp_path + assert backup_path.name.startswith("sym-lib-table.backup.") + assert backup_path.read_text(encoding="utf-8") == "v1" + + +@pytest.mark.usefixtures("frozen_clock") +def test_backups_in_the_same_instant_keep_every_version(tmp_path: Path) -> None: + """Backups taken at the same clock reading get distinct names (issue #12).""" + original = tmp_path / "kicad_common.json" + paths = [] + for version in ("v1", "v2", "v3"): + original.write_text(version, encoding="utf-8") + paths.append(create_backup(original)) + + assert len(set(paths)) == 3 + assert [p.read_text(encoding="utf-8") for p in paths] == ["v1", "v2", "v3"] + # Name order is creation order, which the history pruning relies on. + assert sorted(paths) == paths + + +@pytest.mark.usefixtures("frozen_clock") +def test_history_keeps_the_newest_backups(tmp_path: Path) -> None: + """Pruning removes the oldest backups, even within one clock reading.""" + original = tmp_path / "fp-lib-table" + for version in ("v1", "v2", "v3"): + original.write_text(version, encoding="utf-8") + create_backup(original, max_backups=2) + + kept = sorted(tmp_path.glob("fp-lib-table.backup.*")) + assert [p.read_text(encoding="utf-8") for p in kept] == ["v2", "v3"] + + +def test_missing_file_raises(tmp_path: Path) -> None: + """Backing up a file that does not exist fails without creating anything.""" + with pytest.raises(FileNotFoundError, match="File not found"): + create_backup(tmp_path / "absent") + assert not list(tmp_path.iterdir()) diff --git a/tests/test_command_docs.py b/tests/test_command_docs.py index 0611a8b..8f8b2ee 100644 --- a/tests/test_command_docs.py +++ b/tests/test_command_docs.py @@ -63,7 +63,8 @@ def _subtree(command: click.Command) -> list[click.Command]: def _subtree_flags(command: click.Command) -> frozenset[str]: - return frozenset().union(*(_own_flags(sub) for sub in _subtree(command))) + """Return the flags of ``command`` and every nested subcommand.""" + return frozenset(flag for sub in _subtree(command) for flag in _own_flags(sub)) def _package_dir(command: click.Command) -> Path: diff --git a/tests/test_config_commands.py b/tests/test_config_commands.py index f76d34d..094664c 100644 --- a/tests/test_config_commands.py +++ b/tests/test_config_commands.py @@ -1,169 +1,268 @@ -""" -Tests for KiCad Library Manager config commands. -""" +"""Tests for the `kilm config` commands.""" + +from pathlib import Path import pytest -from typer.testing import CliRunner +import yaml +from typer.testing import CliRunner, Result -from kicad_lib_manager.main import app as main +from kicad_lib_manager.commands.config import command, config_app from kicad_lib_manager.services.config_service import Config, LibraryDict - -# Sample test data -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"), -] +from kicad_lib_manager.utils.constants import CLOUD_METADATA_FILE, GITHUB_METADATA_FILE @pytest.fixture -def mock_config(monkeypatch): - """Create a mock config for testing.""" - config = Config() +def libraries(tmp_path: Path) -> list[LibraryDict]: + """A GitHub and a cloud library whose directories exist.""" + github = tmp_path / "github-lib" + cloud = tmp_path / "cloud-lib" + github.mkdir() + cloud.mkdir() + return [ + LibraryDict(name="test-github-lib", path=str(github), type="github"), + LibraryDict(name="test-cloud-lib", path=str(cloud), type="cloud"), + ] - # Mock the _load_config method to not actually load from disk - def mock_load_config(): - config._config = { - "max_backups": 5, - "libraries": TEST_LIBRARIES.copy(), - "current_library": TEST_LIBRARIES[0]["path"], - } - monkeypatch.setattr(config, "_load_config", mock_load_config) +@pytest.fixture +def config_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, libraries: list[LibraryDict] +) -> Path: + """A configuration with both libraries, the GitHub one as default. + + The commands load it instead of the user's configuration. + """ + path = tmp_path / "config.yaml" + settings = {"libraries": libraries, "current_library": libraries[0]["path"]} + path.write_text(yaml.dump(settings), encoding="utf-8") + monkeypatch.setattr( + command, "Config", lambda: Config(config_file=path, cache_dir=tmp_path) + ) + return path - # Mock the save method to not actually write to disk - monkeypatch.setattr(config, "save", lambda: None) - # Return the config - config._load_config() - return config +def stored(config_file: Path) -> Config: + """The configuration as currently saved.""" + return Config(config_file=config_file, cache_dir=config_file.parent) -@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 - ) - return mock_config +def invoke(*args: str, user_input: str | None = None) -> Result: + """Run `kilm config` with args.""" + return CliRunner().invoke(config_app, list(args), input=user_input) -def test_config_list(mock_config_class): - """Test the 'kilm config list' command.""" - runner = CliRunner() - result = runner.invoke(main, ["config", "list"]) +@pytest.mark.usefixtures("config_file") +def test_list_shows_both_types_and_the_default() -> None: + """The table lists each type and marks the default library.""" + result = invoke("list") assert result.exit_code == 0 assert "GitHub Libraries" in result.output assert "Cloud Libraries" in result.output assert "test-github-lib" in result.output assert "test-cloud-lib" in result.output - assert "✓ Current" in result.output # Current library should be marked - + assert "Current" in result.output + assert "GitHub: 1 Cloud: 1" in result.output + + +@pytest.mark.usefixtures("config_file") +def test_list_verbose_shows_metadata(libraries: list[LibraryDict]) -> None: + """Verbose panels show stored metadata, or count models without it.""" + github = Path(libraries[0]["path"]) + (github / GITHUB_METADATA_FILE).write_text( + yaml.dump( + { + "description": "Company parts", + "version": "1.2.0", + "capabilities": {"symbols": True, "footprints": False}, + } + ), + encoding="utf-8", + ) + cloud = Path(libraries[1]["path"]) + (cloud / "sub").mkdir() + (cloud / "sub" / "part.step").touch() + (cloud / "part.wrl").touch() + (cloud / "notes.txt").touch() -def test_config_list_verbose(mock_config_class): - """Test the 'kilm config list --verbose' command.""" - runner = CliRunner() - result = runner.invoke(main, ["config", "list", "--verbose"]) + result = invoke("list", "--verbose") assert result.exit_code == 0 - assert "GitHub Libraries" in result.output - assert "Cloud Libraries" in result.output - assert "test-github-lib" in result.output - assert "test-cloud-lib" in result.output - assert "✓ CURRENT" in result.output + assert "CURRENT" in result.output + assert "Description: Company parts" in result.output + assert "Version: 1.2.0" in result.output + assert "Features: Symbols" in result.output + assert f"No {CLOUD_METADATA_FILE} metadata file found" in result.output + assert "3D Models: 2 (counted)" in result.output -def test_config_list_filtered(mock_config_class): - """Test the 'kilm config list --type github' command.""" - runner = CliRunner() - result = runner.invoke(main, ["config", "list", "--type", "github"]) +@pytest.mark.usefixtures("config_file") +def test_list_filtered_by_type() -> None: + """--type shows only libraries of that type.""" + result = invoke("list", "--type", "github") assert result.exit_code == 0 - assert "GitHub Libraries" in result.output assert "test-github-lib" in result.output assert "Cloud Libraries" not in result.output assert "test-cloud-lib" not in result.output -def test_config_set_default(mock_config_class): - """Test the 'kilm config set-default' command.""" - runner = CliRunner() - result = runner.invoke( - main, ["config", "set-default", "test-cloud-lib", "--type", "cloud"] - ) +@pytest.mark.parametrize( + ("library_type", "removed", "message", "hint"), + [ + ("github", "test-github-lib", "No GitHub libraries configured", "kilm init"), + ("cloud", "test-cloud-lib", "No cloud libraries configured", "kilm add-3d"), + ], +) +def test_list_type_without_libraries( + config_file: Path, library_type: str, removed: str, message: str, hint: str +) -> None: + """A type filter without matches names that type and how to add one.""" + stored(config_file).remove_library(removed) + + result = invoke("list", "--type", library_type) assert result.exit_code == 0 - assert "Set cloud library 'test-cloud-lib' as default" in result.output - assert mock_config_class.get_current_library() == TEST_LIBRARIES[1]["path"] + assert message in result.output + assert hint in result.output -def test_config_set_default_interactive(mock_config_class): - """Test the interactive 'kilm config set-default' command.""" - runner = CliRunner() - # Simulate user selecting the cloud library (option 2) - result = runner.invoke( - main, ["config", "set-default", "--type", "cloud"], input="1\n" - ) +def test_list_without_libraries(config_file: Path) -> None: + """Without any library the list explains how to add one.""" + config = stored(config_file) + config.remove_library("test-github-lib") + config.remove_library("test-cloud-lib") + + result = invoke("list") + + assert result.exit_code == 0 + assert "No libraries configured" in result.output + + +def test_set_default_by_name(config_file: Path, libraries: list[LibraryDict]) -> None: + """A named library of the given type becomes the saved default.""" + result = invoke("set-default", "test-cloud-lib", "--type", "cloud") assert result.exit_code == 0 - assert "Available cloud libraries:" in result.output - assert "1. test-cloud-lib" in result.output assert "Set cloud library 'test-cloud-lib' as default" in result.output - assert mock_config_class.get_current_library() == TEST_LIBRARIES[1]["path"] + assert stored(config_file).get_current_library() == libraries[1]["path"] -def test_config_set_default_interactive_github(mock_config_class): - """Test the interactive 'kilm config set-default' command with GitHub libraries.""" - runner = CliRunner() - # Simulate user selecting the GitHub library (option 1) - result = runner.invoke(main, ["config", "set-default"], input="1\n") +@pytest.mark.parametrize(("arguments", "index"), [((), 0), (("--type", "cloud"), 1)]) +def test_set_default_prompts_without_name( + config_file: Path, + libraries: list[LibraryDict], + arguments: tuple[str, ...], + index: int, +) -> None: + """Without a name the libraries of the type are numbered and one is picked.""" + library = libraries[index] + config = stored(config_file) + config.add_library("second-lib", "/second", library["type"]) + second = config.get_libraries()[-1] + + result = invoke("set-default", *arguments, user_input="5\n2\n") assert result.exit_code == 0 - assert "Available github libraries:" in result.output - assert "1. test-github-lib" in result.output - assert "Set github library 'test-github-lib' as default" in result.output - assert mock_config_class.get_current_library() == TEST_LIBRARIES[0]["path"] + assert f"Available {library['type']} libraries:" in result.output + assert f"1. {library['name']}" in result.output + assert "2. second-lib" in result.output + assert "5 is not in the range 1<=x<=2" in result.output + assert stored(config_file).get_current_library() == second["path"] -def test_config_set_default_not_found(mock_config_class): - """Test 'kilm config set-default' with non-existent library.""" - runner = CliRunner() - result = runner.invoke(main, ["config", "set-default", "non-existent-lib"]) +@pytest.mark.usefixtures("config_file") +def test_set_default_unknown_name() -> None: + """An unknown name fails and points to config list.""" + result = invoke("set-default", "non-existent-lib") - assert result.exit_code != 0 + assert result.exit_code == 1 assert "No github library named 'non-existent-lib' found" in result.output + assert "kilm config list" in result.output + + +def test_set_default_without_libraries_of_type(config_file: Path) -> None: + """Without libraries of the type the command fails and says how to add one.""" + stored(config_file).remove_library("test-cloud-lib") + result = invoke("set-default", "--type", "cloud") -def test_config_remove(mock_config_class): - """Test the 'kilm config remove' command.""" - runner = CliRunner() - # Use --force to bypass confirmation prompt - result = runner.invoke(main, ["config", "remove", "test-github-lib", "--force"]) + assert result.exit_code == 1 + assert "No cloud libraries configured." in result.output + assert "kilm add-3d" in result.output + + +def test_remove_with_force(config_file: Path, libraries: list[LibraryDict]) -> None: + """--force removes without asking and saves the configuration.""" + result = invoke("remove", "test-cloud-lib", "--force") assert result.exit_code == 0 - assert "Removed library 'test-github-lib'" in result.output + assert "Removed library 'test-cloud-lib'" in result.output + assert "Note:" not in result.output + assert stored(config_file).get_libraries() == [libraries[0]] - # Verify the library was removed - remaining_libraries = mock_config_class.get_libraries() - assert len(remaining_libraries) == 1 - assert remaining_libraries[0]["name"] == "test-cloud-lib" +def test_remove_default_library_reports_cleared_default(config_file: Path) -> None: + """Removing the default library says that no default is set any more.""" + result = invoke("remove", "test-github-lib", "--force") -def test_config_remove_with_confirmation(mock_config_class): - """Test 'kilm config remove' with confirmation prompt.""" - runner = CliRunner() - # Simulate user confirming the removal - result = runner.invoke(main, ["config", "remove", "test-github-lib"], input="y\n") + assert result.exit_code == 0 + assert "no default is set now" in result.output + assert stored(config_file).get_current_library() is None + + +def test_remove_asks_for_confirmation( + config_file: Path, libraries: list[LibraryDict] +) -> None: + """Without --force the libraries are described and removal needs a yes.""" + result = invoke("remove", "test-github-lib", user_input="y\n") assert result.exit_code == 0 assert "Will remove github library 'test-github-lib'" in result.output + assert "This is the current default library" in result.output assert "Removed library 'test-github-lib'" in result.output + assert stored(config_file).get_libraries() == [libraries[1]] + + +def test_remove_cancelled_keeps_library(config_file: Path) -> None: + """Declining the confirmation keeps the configuration unchanged.""" + result = invoke("remove", "test-github-lib", user_input="n\n") + + assert result.exit_code == 0 + assert "Operation cancelled." in result.output + assert len(stored(config_file).get_libraries()) == 2 + + +def test_remove_only_the_given_type( + config_file: Path, libraries: list[LibraryDict] +) -> None: + """--type removes one entry of a name registered for both types.""" + config = stored(config_file) + config.add_library("test-github-lib", "/elsewhere", "cloud") + + result = invoke("remove", "test-github-lib", "--type", "cloud", "--force") + + assert result.exit_code == 0 + assert stored(config_file).get_libraries() == libraries + + +def test_remove_every_type_reports_count( + config_file: Path, libraries: list[LibraryDict] +) -> None: + """Without --type every entry with the name goes, and the count is given.""" + stored(config_file).add_library("test-github-lib", "/elsewhere", "cloud") + + result = invoke("remove", "test-github-lib", "--force") + + assert result.exit_code == 0 + assert "Removed 2 instances of library 'test-github-lib'" in result.output + assert stored(config_file).get_libraries() == [libraries[1]] -def test_config_remove_not_found(mock_config_class): - """Test 'kilm config remove' with non-existent library.""" - runner = CliRunner() - result = runner.invoke(main, ["config", "remove", "non-existent-lib", "--force"]) +@pytest.mark.usefixtures("config_file") +def test_remove_unknown_name() -> None: + """An unknown name fails and points to config list.""" + result = invoke("remove", "non-existent-lib", "--force") - assert result.exit_code != 0 + assert result.exit_code == 1 assert "No library named 'non-existent-lib' found" in result.output diff --git a/tests/test_config_service.py b/tests/test_config_service.py new file mode 100644 index 0000000..440b3b7 --- /dev/null +++ b/tests/test_config_service.py @@ -0,0 +1,240 @@ +"""Tests for the KiLM configuration file model.""" + +import time +from pathlib import Path +from typing import cast + +import pytest +import yaml + +from kicad_lib_manager.services.config_service import ( + LAST_UPDATE_CHECK_FILE, + Config, + LibraryDict, +) +from kicad_lib_manager.utils.constants import DEFAULT_MAX_BACKUPS + +GITHUB = LibraryDict(name="symbols", path="/libs/symbols", type="github") +CLOUD = LibraryDict(name="models", path="/libs/models", type="cloud") + + +@pytest.fixture +def config_file(tmp_path: Path) -> Path: + """Location of a configuration file that does not exist yet.""" + return tmp_path / "config" / "config.yaml" + + +def make_config(config_file: Path, settings: object | None = None) -> Config: + """Write settings (if any) to config_file and load it.""" + if settings is not None: + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(yaml.dump(settings), encoding="utf-8") + return Config(config_file=config_file, cache_dir=config_file.parent / "cache") + + +def saved(config_file: Path) -> dict[str, object]: + """The settings currently stored in config_file.""" + loaded: object = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert isinstance(loaded, dict) + return cast("dict[str, object]", loaded) + + +def test_missing_file_yields_defaults_without_creating_anything( + config_file: Path, +) -> None: + """A missing file means the defaults, and loading creates no directory.""" + config = make_config(config_file) + + assert config.get_libraries() == [] + assert config.get("update_check") is True + assert config.get("max_backups") == 5 + assert not config_file.parent.exists() + + +def test_loading_merges_settings_and_drops_invalid_libraries( + config_file: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """File settings override defaults; incomplete library entries are dropped.""" + config = make_config( + config_file, + {"max_backups": 2, "libraries": [dict(GITHUB), {"name": "no-path"}]}, + ) + + assert config.get("max_backups") == 2 + assert config.get("update_check_frequency") == "daily" + assert config.get_libraries() == [GITHUB] + assert "Skipping invalid library entry" in capsys.readouterr().err + + +def test_non_list_libraries_setting_becomes_empty( + config_file: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A libraries setting that is not a list is replaced by an empty one.""" + config = make_config(config_file, {"libraries": "oops"}) + + assert config.get_libraries() == [] + assert "resetting to empty list" in capsys.readouterr().err + + +def test_unreadable_file_is_reported_and_yields_defaults( + config_file: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Invalid YAML is reported on stderr and the defaults apply.""" + config_file.parent.mkdir(parents=True) + config_file.write_text("libraries: [unclosed", encoding="utf-8") + + config = make_config(config_file) + + assert config.get_libraries() == [] + assert "Error loading config file" in capsys.readouterr().err + + +def test_undecodable_file_is_reported_and_yields_defaults( + config_file: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A file that is not UTF-8 is reported on stderr and the defaults apply.""" + config_file.parent.mkdir(parents=True) + config_file.write_bytes(b"max_backups: \xff\n") + + config = make_config(config_file) + + assert config.get("max_backups") == DEFAULT_MAX_BACKUPS + assert "Error loading config file" in capsys.readouterr().err + + +def test_add_library_saves_and_updates_existing_entry(config_file: Path) -> None: + """Adding a known name and type updates its path; every change is saved.""" + config = make_config(config_file) + + config.add_library("symbols", "/old", "github") + config.add_library("symbols", GITHUB["path"], "github") + config.add_library(CLOUD["name"], CLOUD["path"], "cloud") + + assert config.get_libraries() == [GITHUB, CLOUD] + assert config.get_libraries("cloud") == [CLOUD] + assert make_config(config_file).get_libraries() == [GITHUB, CLOUD] + + +def test_remove_library_by_type_or_every_type(config_file: Path) -> None: + """Removal is limited to one type when given and reports whether it removed.""" + twin = LibraryDict(name="symbols", path="/libs/twin", type="cloud") + config = make_config(config_file, {"libraries": [GITHUB, twin, CLOUD]}) + + assert config.remove_library("symbols", "cloud") + assert config.get_libraries() == [GITHUB, CLOUD] + assert config.remove_library("symbols") + assert not config.remove_library("symbols") + assert saved(config_file)["libraries"] == [CLOUD] + + +def test_removing_the_default_library_clears_the_default(config_file: Path) -> None: + """No default remains once no registered library has its path.""" + config = make_config( + config_file, {"libraries": [GITHUB, CLOUD], "current_library": GITHUB["path"]} + ) + + config.remove_library(CLOUD["name"]) + assert config.get_current_library() == GITHUB["path"] + + config.remove_library(GITHUB["name"]) + + assert config.get_current_library() is None + assert "current_library" not in saved(config_file) + + +def test_default_survives_while_another_entry_has_its_path(config_file: Path) -> None: + """Removing one of two entries sharing the default path keeps the default.""" + twin = LibraryDict(name="twin", path=GITHUB["path"], type="cloud") + config = make_config( + config_file, {"libraries": [GITHUB, twin], "current_library": GITHUB["path"]} + ) + + config.remove_library(GITHUB["name"]) + + assert config.get_current_library() == GITHUB["path"] + + +def test_library_paths_prefer_the_default_of_matching_type(config_file: Path) -> None: + """The default library wins for its own type; others fall back to the first.""" + other = LibraryDict(name="other", path="/libs/other", type="github") + config = make_config(config_file, {"libraries": [GITHUB, other, CLOUD]}) + + assert config.get_symbol_library_path() == GITHUB["path"] + assert config.get_3d_library_path() == CLOUD["path"] + + config.set_current_library(other["path"]) + + assert config.get_current_library() == other["path"] + assert config.get_symbol_library_path() == other["path"] + assert config.get_3d_library_path() == CLOUD["path"] + assert saved(config_file)["current_library"] == other["path"] + + +def test_library_paths_are_none_without_libraries(config_file: Path) -> None: + """Without libraries there is no default and no path to use.""" + config = make_config(config_file) + + assert config.get_symbol_library_path() is None + assert config.get_3d_library_path() is None + assert config.get_current_library() is None + + +DAY = 24 * 60 * 60 + + +def config_with_last_check( + config_file: Path, settings: dict[str, object], seconds_ago: float | None +) -> Config: + """Load settings, with the last update check seconds_ago (None: never).""" + config = make_config(config_file, settings) + if seconds_ago is not None: + cache_dir = config_file.parent / "cache" + cache_dir.mkdir() + (cache_dir / LAST_UPDATE_CHECK_FILE).write_text(str(time.time() - seconds_ago)) + return config + + +@pytest.mark.parametrize( + ("settings", "seconds_ago"), + [ + ({}, None), + ({}, 2 * DAY), + ({"update_check_frequency": "weekly"}, 8 * DAY), + ({"update_check_frequency": "monthly"}, None), + ], +) +def test_update_check_is_due( + config_file: Path, settings: dict[str, object], seconds_ago: float | None +) -> None: + """A check is due without a record or once the interval has passed.""" + config = config_with_last_check(config_file, settings, seconds_ago) + + assert config.should_check_updates() + + +@pytest.mark.parametrize( + ("settings", "seconds_ago"), + [ + ({}, 60), + ({"update_check_frequency": "weekly"}, 2 * DAY), + ({"update_check_frequency": "never"}, None), + ({"update_check": False}, None), + ({"update_check_frequency": "monthly"}, 8 * DAY), + ], +) +def test_update_check_is_not_due( + config_file: Path, settings: dict[str, object], seconds_ago: float | None +) -> None: + """Checks wait for the interval, and disabled checks never run.""" + config = config_with_last_check(config_file, settings, seconds_ago) + + assert not config.should_check_updates() + + +def test_marking_a_check_postpones_the_next(config_file: Path) -> None: + """Recording a check makes the next one wait for the interval.""" + config = make_config(config_file) + + config.mark_update_check_performed() + + assert not config.should_check_updates() diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..1992ca5 --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,56 @@ +"""Rich output follows the stream that is current when output is written.""" + +import importlib +import io +import pkgutil + +import pytest +from rich.console import Console + +import kicad_lib_manager +from kicad_lib_manager.utils.console import get_console + + +class TerminalStream: + """A minimal text stream that reports itself as a terminal.""" + + def write(self, text: str) -> int: + """Discard the text; only terminal detection matters here.""" + return len(text) + + def flush(self) -> None: + """Nothing is buffered.""" + + def isatty(self) -> bool: + """Claim to be a terminal so Rich enables color.""" + return True + + +def test_no_module_holds_a_console() -> None: + """Importing the package creates no Console that would freeze color detection.""" + holders: list[str] = [] + for module_info in pkgutil.walk_packages( + kicad_lib_manager.__path__, f"{kicad_lib_manager.__name__}." + ): + module = importlib.import_module(module_info.name) + holders.extend( + f"{module_info.name}.{name}" + for name, value in vars(module).items() + if isinstance(value, Console) + ) + assert not holders, f"Module-level consoles: {holders}" + + +def test_get_console_detects_the_current_stdout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A console obtained after redirecting stdout reflects the new stream.""" + for name in ("NO_COLOR", "FORCE_COLOR", "TTY_COMPATIBLE", "TTY_INTERACTIVE"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("TERM", "xterm-256color") + + monkeypatch.setattr("sys.stdout", TerminalStream()) + assert get_console().color_system is not None + + monkeypatch.setattr("sys.stdout", io.StringIO()) + assert get_console().color_system is None diff --git a/tests/test_env_vars.py b/tests/test_env_vars.py index 10a5753..349c7e3 100644 --- a/tests/test_env_vars.py +++ b/tests/test_env_vars.py @@ -1,12 +1,21 @@ +"""Tests for KiCad environment variables and pinned libraries.""" + import json +from collections.abc import Callable from pathlib import Path import pytest -from kicad_lib_manager.utils.env_vars import update_kicad_env_vars +from kicad_lib_manager.utils import env_vars +from kicad_lib_manager.utils.env_vars import ( + find_library_variable, + update_kicad_env_vars, + update_pinned_libraries, +) +from tests.fault_injection import fail_writes -def test_update_kicad_env_vars(tmp_path): +def test_update_kicad_env_vars(tmp_path: Path) -> None: """Test updating environment variables in KiCad configuration.""" # Create a temporary KiCad configuration directory kicad_config = tmp_path / "kicad" @@ -95,5 +104,93 @@ def test_update_kicad_env_vars(tmp_path): with Path(kicad_common).open("w") as f: f.write("invalid json") - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid JSON format"): update_kicad_env_vars(kicad_config, env_vars) + + +@pytest.mark.parametrize( + "update", + [ + lambda config: update_kicad_env_vars(config, {"KICAD_USER_LIB": "/new"}), + lambda config: update_pinned_libraries(config, symbol_libs=["NewLib"]), + ], + ids=["env_vars", "pinned_libraries"], +) +def test_failed_write_keeps_kicad_common( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + update: Callable[[Path], bool], +) -> None: + """A write that fails part way leaves kicad_common.json as it was.""" + kicad_common = tmp_path / "kicad_common.json" + original = json.dumps({"environment": {"vars": {}}, "session": {}}) + kicad_common.write_text(original, encoding="utf-8") + fail_writes(monkeypatch) + + with pytest.raises(OSError, match="No space left on device"): + update(tmp_path) + + assert kicad_common.read_text(encoding="utf-8") == original + + +def test_pinning_adds_new_libraries_once(tmp_path: Path) -> None: + """Pins are appended to those already there; repeats and dry runs write nothing.""" + kicad_common = tmp_path / "kicad_common.json" + session = {"pinned_symbol_libs": ["Old"], "pinned_fp_libs": []} + kicad_common.write_text(json.dumps({"session": session}), encoding="utf-8") + + assert not update_pinned_libraries(tmp_path, symbol_libs=["Old"]) + assert update_pinned_libraries(tmp_path, footprint_libs=["Fp"], dry_run=True) + assert json.loads(kicad_common.read_text(encoding="utf-8"))["session"] == session + + assert update_pinned_libraries( + tmp_path, symbol_libs=["New", "Old", "New"], footprint_libs=["Fp"] + ) + assert json.loads(kicad_common.read_text(encoding="utf-8"))["session"] == { + "pinned_symbol_libs": ["Old", "New"], + "pinned_fp_libs": ["Fp"], + } + assert not update_pinned_libraries(tmp_path, symbol_libs=["New"]) + + +class _FakeConfig: + """A KiLM configuration whose current symbol library is library_path.""" + + library_path: str | None = None + + def get_symbol_library_path(self) -> str | None: + """The configured symbol library path.""" + return self.library_path + + +@pytest.fixture +def isolated_sources( + home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Path: + """No source defines KICAD_USER_LIB; returns the empty home directory.""" + monkeypatch.delenv("KICAD_USER_LIB", raising=False) + monkeypatch.setattr(env_vars, "Config", _FakeConfig) + monkeypatch.setattr(env_vars.shutil, "which", lambda _name: None) + monkeypatch.chdir(tmp_path) + return home + + +def test_sources_are_tried_in_order( + isolated_sources: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Shell files < environment < library in cwd < KiLM configuration.""" + assert find_library_variable("KICAD_USER_LIB") is None + + (isolated_sources / ".zshrc").write_text( + 'export KICAD_USER_LIB="/from/zshrc"\n', encoding="utf-8" + ) + assert find_library_variable("KICAD_USER_LIB") == "/from/zshrc" + + monkeypatch.setenv("KICAD_USER_LIB", "/from/environment") + assert find_library_variable("KICAD_USER_LIB") == "/from/environment" + + (tmp_path / "kilm.yaml").write_text("name: here\n", encoding="utf-8") + assert find_library_variable("KICAD_USER_LIB") == str(tmp_path.resolve()) + + monkeypatch.setattr(_FakeConfig, "library_path", "/from/config") + assert find_library_variable("KICAD_USER_LIB") == "/from/config" diff --git a/tests/test_file_ops.py b/tests/test_file_ops.py new file mode 100644 index 0000000..34cf2fd --- /dev/null +++ b/tests/test_file_ops.py @@ -0,0 +1,33 @@ +"""Tests for file writes that must never leave a file half-written.""" + +from pathlib import Path + +import pytest + +from kicad_lib_manager.utils.file_ops import write_file_with_encoding + + +def test_failed_write_keeps_the_old_file(tmp_path: Path) -> None: + """Content that cannot be encoded leaves the existing file unchanged. + + Library names come from file names, which can carry undecodable bytes as + lone surrogates; writing one must not wipe the library table. + """ + table = tmp_path / "sym-lib-table" + table.write_text("(sym_lib_table\n)\n", encoding="utf-8") + + with pytest.raises(UnicodeEncodeError): + write_file_with_encoding(table, '(lib (name "bad\udcff"))') + + assert table.read_text(encoding="utf-8") == "(sym_lib_table\n)\n" + assert [p.name for p in tmp_path.iterdir()] == ["sym-lib-table"] + + +def test_write_replaces_the_content(tmp_path: Path) -> None: + """A successful write replaces the whole file.""" + table = tmp_path / "fp-lib-table" + table.write_text("old content that is longer", encoding="utf-8") + + write_file_with_encoding(table, "new") + + assert table.read_text(encoding="utf-8") == "new" diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index 235de4f..7170b26 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -1,6 +1,4 @@ -""" -Tests for Git utility functions. -""" +"""Tests for Git utility functions.""" import os from pathlib import Path @@ -8,6 +6,7 @@ import pytest +from kicad_lib_manager.utils import git_utils from kicad_lib_manager.utils.git_utils import ( backup_existing_hook, create_kilm_hook_content, @@ -19,7 +18,7 @@ class TestGitUtils: """Test cases for Git utility functions.""" - def test_create_kilm_hook_content(self): + def test_create_kilm_hook_content(self) -> None: """Test that KiLM hook content is created with proper markers.""" content = create_kilm_hook_content() @@ -30,7 +29,7 @@ def test_create_kilm_hook_content(self): assert content.startswith("# BEGIN KiLM-managed section") assert content.endswith("# END KiLM-managed section") - def test_backup_existing_hook(self, tmp_path): + def test_backup_existing_hook(self, tmp_path: Path) -> None: """Test backup creation with timestamp.""" hook_file = tmp_path / "post-merge" hook_content = "#!/bin/sh\necho 'test hook'" @@ -48,7 +47,7 @@ def test_backup_existing_hook(self, tmp_path): if os.name != "nt": # Not Windows assert backup_path.stat().st_mode & 0o111 # Check executable bit - def test_merge_hook_content_new_content(self): + def test_merge_hook_content_new_content(self) -> None: """Test merging when no KiLM content exists.""" existing = "#!/bin/sh\necho 'existing hook'" kilm_content = create_kilm_hook_content() @@ -60,7 +59,7 @@ def test_merge_hook_content_new_content(self): assert "END KiLM-managed section" in result assert result.count("BEGIN KiLM-managed section") == 1 - def test_merge_hook_content_replace_existing(self): + def test_merge_hook_content_replace_existing(self) -> None: """Test merging when KiLM content already exists.""" existing = """#!/bin/sh echo 'existing hook' @@ -80,70 +79,35 @@ def test_merge_hook_content_replace_existing(self): assert "old kilm content" not in result assert "kilm sync" in result + @pytest.mark.parametrize("relative", [True, False]) @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 - mock_run.return_value = Mock(returncode=0, stdout=".git/hooks") - - repo_path = tmp_path / "test-repo" - repo_path.mkdir() - (repo_path / ".git" / "hooks").mkdir(parents=True, exist_ok=True) - - hooks_dir = get_git_hooks_directory(repo_path) - - assert hooks_dir == repo_path / ".git" / "hooks" - mock_run.assert_called_once() - - @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" - custom_hooks.mkdir(parents=True, exist_ok=True) - - mock_run.return_value = Mock(returncode=0, stdout=str(custom_hooks)) - - repo_path = tmp_path / "test-repo" - repo_path.mkdir() - (repo_path / ".git").mkdir() - - hooks_dir = get_git_hooks_directory(repo_path) - - assert hooks_dir == custom_hooks - mock_run.assert_called_once() - - @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") + def test_get_git_hooks_directory_asks_git( + self, mock_run: Mock, tmp_path: Path, *, relative: bool + ) -> None: + """Git's hooks path is used, relative to the repository or as given. - repo_path = tmp_path / "test-repo" + core.hooksPath and worktrees reach this as such a path. + """ + repo_path = tmp_path / "repo" repo_path.mkdir() - (repo_path / ".git").mkdir() - (repo_path / "custom" / "hooks").mkdir(parents=True, exist_ok=True) - - hooks_dir = get_git_hooks_directory(repo_path) - - assert hooks_dir == repo_path / "custom" / "hooks" + if relative: + hooks = repo_path / "custom" / "hooks" + mock_run.return_value = Mock(returncode=0, stdout="custom/hooks") + else: + hooks = tmp_path / "main-repo" / ".git" / "hooks" + mock_run.return_value = Mock(returncode=0, stdout=str(hooks)) + + assert get_git_hooks_directory(repo_path) == hooks + assert hooks.is_dir() mock_run.assert_called_once() - @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 - main_git_dir = tmp_path / "main-repo" / ".git" - worktree_hooks = main_git_dir / "hooks" - worktree_hooks.mkdir(parents=True, exist_ok=True) - - mock_run.return_value = Mock(returncode=0, stdout=str(worktree_hooks)) - - repo_path = tmp_path / "worktree" - repo_path.mkdir() - - hooks_dir = get_git_hooks_directory(repo_path) - - assert hooks_dir == worktree_hooks - mock_run.assert_called_once() + def test_get_git_hooks_directory_without_git( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing git executable is reported, not raised as FileNotFoundError.""" + monkeypatch.setattr(git_utils.shutil, "which", lambda _name: None) + with pytest.raises(RuntimeError, match="Git is not installed"): + get_git_hooks_directory(tmp_path) def test_get_git_hooks_directory_not_repo( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch diff --git a/tests/test_github_release.py b/tests/test_github_release.py index 6fd3e84..4af59cf 100644 --- a/tests/test_github_release.py +++ b/tests/test_github_release.py @@ -2,15 +2,28 @@ from __future__ import annotations -from collections.abc import Sequence +import json from dataclasses import replace -from pathlib import Path +from typing import TYPE_CHECKING import pytest -from github_release import Release, ReleaseError, file_digest, publish_release +from github_release import ( + Publication, + Release, + ReleaseError, + file_digest, + has_tag_ref, + parse_release, + publish_release, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path TAG = "v1.2.3" COMMIT = "a" * 40 +OTHER_COMMIT = "b" * 40 class JobInterruptedError(Exception): @@ -18,22 +31,29 @@ class JobInterruptedError(Exception): class FakeGitHub: - """In-memory GitHub whose writes can fail after taking effect.""" + """In-memory GitHub whose writes can fail after taking effect. + + ``writes`` records each applied write; setting ``fail_after`` to a write's + name raises JobInterruptedError right after that write is applied. + """ def __init__(self) -> None: + """Start with no releases, no tags, and no injected failure.""" self.releases: dict[str, Release] = {} self.tags: dict[str, str] = {} self.writes: list[str] = [] self.fail_after: str | None = None def release(self, tag: str) -> Release | None: + """Return the stored release, draft or published.""" return self.releases.get(tag) def tag_commit(self, tag: str) -> str | None: + """Return the stored tag target.""" return self.tags.get(tag) def create_draft(self, tag: str, target: str, title: str, notes: str) -> None: - # gh release create rejects a tag that already has a release. + """Store an empty draft; like gh, refuse a tag that already has a release.""" assert tag not in self.releases, f"release {tag} already exists" assert title assert notes @@ -41,14 +61,15 @@ def create_draft(self, tag: str, target: str, title: str, notes: str) -> None: self._wrote("create_draft") def upload(self, tag: str, files: Sequence[Path]) -> None: + """Store the files one by one, so a failure leaves a partial upload.""" release = self.releases[tag] for path in files: assets = {**release.assets, path.name: file_digest(path)} release = self.releases[tag] = replace(release, assets=assets) - # Failing here leaves a partial upload: only the first file arrived. self._wrote("upload") def publish(self, tag: str) -> None: + """Publish the draft and create the tag at its target if it is missing.""" release = self.releases[tag] self.tags.setdefault(tag, release.target) self.releases[tag] = replace(release, draft=False) @@ -61,27 +82,32 @@ def _wrote(self, operation: str) -> None: @pytest.fixture -def dist(tmp_path: Path) -> list[Path]: - files = [tmp_path / "kilm-1.2.3.tar.gz", tmp_path / "kilm-1.2.3-py3-none-any.whl"] +def dist(tmp_path: Path) -> tuple[Path, ...]: + """Two built files with distinct contents.""" + files = (tmp_path / "kilm-1.2.3.tar.gz", tmp_path / "kilm-1.2.3-py3-none-any.whl") for path in files: path.write_bytes(path.name.encode()) return files -def publish(github: FakeGitHub, files: list[Path]) -> None: - publish_release( - github, tag=TAG, commit=COMMIT, files=files, title="Release", notes="Notes" +def publish(github: FakeGitHub, files: tuple[Path, ...]) -> None: + """Publish ``files`` as release TAG of COMMIT.""" + publication = Publication( + tag=TAG, commit=COMMIT, files=files, title="Release", notes="Notes" ) + publish_release(github, publication) -def assert_published(github: FakeGitHub, files: list[Path]) -> None: +def assert_published(github: FakeGitHub, files: tuple[Path, ...]) -> None: + """Assert TAG is a published release of COMMIT holding exactly ``files``.""" release = github.releases[TAG] assert not release.draft assert github.tags[TAG] == COMMIT assert release.assets == {f.name: file_digest(f) for f in files} -def test_first_run_creates_uploads_then_publishes(dist: list[Path]) -> None: +def test_first_run_creates_uploads_then_publishes(dist: tuple[Path, ...]) -> None: + """The draft gets every file before it is published.""" github = FakeGitHub() publish(github, dist) assert github.writes == ["create_draft", "upload", "upload", "publish"] @@ -97,8 +123,9 @@ def test_first_run_creates_uploads_then_publishes(dist: list[Path]) -> None: ], ) def test_rerun_finishes_an_interrupted_publication( - dist: list[Path], fail_after: str, rerun_writes: list[str] + dist: tuple[Path, ...], fail_after: str, rerun_writes: list[str] ) -> None: + """A rerun after any interrupted write does only the remaining writes.""" github = FakeGitHub() github.fail_after = fail_after with pytest.raises(JobInterruptedError): @@ -112,8 +139,9 @@ def test_rerun_finishes_an_interrupted_publication( def test_published_release_with_a_stale_asset_gets_it_replaced( - dist: list[Path], + dist: tuple[Path, ...], ) -> None: + """Only the asset whose digest differs from the built file is uploaded.""" github = FakeGitHub() publish(github, dist) release = github.releases[TAG] @@ -127,16 +155,46 @@ def test_published_release_with_a_stale_asset_gets_it_replaced( assert_published(github, dist) -@pytest.mark.parametrize("draft", [False, True]) +@pytest.mark.parametrize("draft", [False, True], ids=["published", "draft"]) def test_release_of_another_commit_is_rejected_without_writes( - dist: list[Path], draft: bool + dist: tuple[Path, ...], *, draft: bool ) -> None: - other = "b" * 40 + """A release or tag of another commit stops the run before any write.""" github = FakeGitHub() - github.releases[TAG] = Release(draft=draft, target=other) + github.releases[TAG] = Release(draft=draft, target=OTHER_COMMIT) if not draft: - github.tags[TAG] = other + github.tags[TAG] = OTHER_COMMIT - with pytest.raises(ReleaseError, match=other): + with pytest.raises(ReleaseError, match=OTHER_COMMIT): publish(github, dist) assert github.writes == [] + + +def test_parse_release_keeps_only_uploaded_assets_with_a_digest() -> None: + """Assets still uploading or without a digest count as missing.""" + asset = {"name": "done.whl", "state": "uploaded", "digest": "sha256:abc"} + view = { + "isDraft": True, + "targetCommitish": COMMIT, + "assets": [ + asset, + {**asset, "name": "partial.whl", "state": "open"}, + {**asset, "name": "old.whl", "digest": None}, + ], + } + assert parse_release(json.dumps(view)) == Release( + draft=True, target=COMMIT, assets={"done.whl": "abc"} + ) + + +def test_parse_release_rejects_unexpected_output() -> None: + """Output without the requested fields fails with a ReleaseError.""" + with pytest.raises(ReleaseError, match="isDraft"): + parse_release(json.dumps({"targetCommitish": COMMIT, "assets": []})) + + +def test_has_tag_ref_ignores_longer_tags_with_the_same_prefix() -> None: + """matching-refs lists every tag starting with the name; only exact counts.""" + refs = json.dumps([{"ref": f"refs/tags/{TAG}0"}]) + assert not has_tag_ref(refs, TAG) + assert has_tag_ref(json.dumps([{"ref": f"refs/tags/{TAG}"}]), TAG) diff --git a/tests/test_init_command.py b/tests/test_init_command.py new file mode 100644 index 0000000..f7b948a --- /dev/null +++ b/tests/test_init_command.py @@ -0,0 +1,32 @@ +"""Tests for the ``kilm init`` command.""" + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from kicad_lib_manager.main import app + + +@pytest.fixture +def library(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Return an empty library directory, made current.""" + directory = tmp_path / "library" + directory.mkdir() + monkeypatch.chdir(directory) + return directory + + +def test_init_reports_which_folders_it_created(library: Path) -> None: + """Init separates folders that already existed from the ones it created.""" + (library / "symbols").mkdir() + + result = CliRunner().invoke( + app, ["init", "--name", "parts"], env={"COLUMNS": "200"} + ) + + assert result.exit_code == 0, result.output + assert "Found existing folders: symbols\n" in result.output + assert "Created new folders: footprints, templates\n" in result.output + assert (library / "footprints").is_dir() + assert (library / "templates").is_dir() diff --git a/tests/test_install_method.py b/tests/test_install_method.py new file mode 100644 index 0000000..36266f4 --- /dev/null +++ b/tests/test_install_method.py @@ -0,0 +1,266 @@ +"""Tests for installation method detection and the installer commands.""" + +import subprocess +import sys +from pathlib import PurePath, PurePosixPath, PureWindowsPath + +import pytest + +from kicad_lib_manager.services import install_method +from kicad_lib_manager.services.install_method import ( + InstallationDetector, + InstallMethod, + detect_installation_method, + run_upgrade, + update_instruction, + upgrade_command, +) + +PIPX_PYTHON = "/Users/user/.local/share/pipx/venvs/kilm/bin/python" +UV_PYTHON = "/Users/user/.local/share/uv/tools/kilm/bin/python" + + +@pytest.fixture(autouse=True) +def outside_any_environment(monkeypatch: pytest.MonkeyPatch) -> None: + """Detect as if no tool environment were active, whatever runs the tests. + + CI runners and dev shells export these variables (setup-uv on Windows sets + UV_TOOL_DIR, for example), and the test interpreter usually runs in a venv. + """ + for variable in ( + "PIPX_HOME", + "CONDA_DEFAULT_ENV", + "CONDA_PREFIX", + "UV_TOOL_DIR", + "UV_TOOL_BIN_DIR", + "VIRTUAL_ENV", + ): + monkeypatch.delenv(variable, raising=False) + monkeypatch.setattr(sys, "prefix", sys.base_prefix) + + +def detect(executable: PurePath, **kwargs: str) -> InstallMethod: + """Detect the method for an interpreter at `executable`.""" + return InstallationDetector(executable_path=executable, **kwargs).detect() + + +class TestInstallationDetector: + """Which evidence identifies which installation method.""" + + @pytest.mark.parametrize( + ("executable", "expected"), + [ + (PurePosixPath(PIPX_PYTHON), "pipx"), + (PurePosixPath("/opt/miniconda3/envs/kilm/bin/python"), "conda"), + (PurePosixPath(UV_PYTHON), "uv"), + (PurePosixPath("/nix/store/abc123-python3-3.12.9/bin/python3.12"), "nix"), + (PurePosixPath("/opt/homebrew/bin/python3"), "homebrew"), + (PurePosixPath("/usr/bin/python3"), "unknown"), + ( + PureWindowsPath(r"C:\Users\user\pipx\venvs\kilm\Scripts\python.exe"), + "pipx", + ), + ( + PureWindowsPath( + r"C:\Users\user\AppData\Roaming\uv\tools\kilm\Scripts\python.exe" + ), + "uv", + ), + ( + PureWindowsPath( + r"C:\Users\user\AppData\Roaming\uv\data\tools\kilm\Scripts\python.exe" + ), + "uv", + ), + ], + ) + def test_interpreter_location_identifies_the_method( + self, executable: PurePath, expected: InstallMethod + ) -> None: + """Tool environments are recognized by path, with either separator.""" + platform_name = "nt" if isinstance(executable, PureWindowsPath) else "posix" + assert detect(executable, platform_name=platform_name) == expected + + def test_homebrew_paths_do_not_apply_on_windows(self) -> None: + """A Homebrew-looking path on Windows is no Homebrew install.""" + executable = PurePosixPath("/opt/homebrew/bin/python3") + assert detect(executable, platform_name="nt") == "unknown" + + def test_pipx_home_with_a_pipx_interpreter( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """PIPX_HOME moves pipx environments out of the default location.""" + monkeypatch.setenv("PIPX_HOME", "/opt/pipx") + assert detect(PurePosixPath("/opt/pipx/bin/python")) == "pipx" + + def test_conda_prefix_containing_the_interpreter( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An interpreter inside the active CONDA_PREFIX is a conda install.""" + monkeypatch.setenv("CONDA_PREFIX", "/opt/envs/kilm-env") + assert detect(PurePosixPath("/opt/envs/kilm-env/bin/python")) == "conda" + + def test_active_conda_env_alone_is_not_a_conda_install( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Many shells activate conda's base env, whatever installed kilm.""" + monkeypatch.setenv("CONDA_DEFAULT_ENV", "base") + monkeypatch.setenv("CONDA_PREFIX", "/opt/envs/base") + assert detect(PurePosixPath(UV_PYTHON)) == "uv" + + def test_uv_tool_dir_containing_the_interpreter( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An interpreter inside UV_TOOL_DIR is a uv tool install.""" + monkeypatch.setenv("UV_TOOL_DIR", "/custom/uv-tools") + assert detect(PurePosixPath("/custom/uv-tools/kilm/bin/python")) == "uv" + + def test_uv_env_vars_alone_are_not_a_uv_install( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """UV_TOOL_DIR and UV_TOOL_BIN_DIR only say where uv puts tools. + + They are set on CI runners and in shells that use uv for other tools, + so they must not turn a kilm running elsewhere into a uv install. + """ + monkeypatch.setenv("UV_TOOL_DIR", "/Users/user/.local/share/uv/tools") + monkeypatch.setenv("UV_TOOL_BIN_DIR", "/Users/user/.local/bin") + assert detect(PurePosixPath("/usr/bin/python")) == "unknown" + + def test_virtual_env_variable(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An activated virtual environment is a pip-in-venv install.""" + monkeypatch.setenv("VIRTUAL_ENV", "/path/to/venv") + assert detect(PurePosixPath("/path/to/venv/bin/python")) == "pip-venv" + + def test_virtual_env_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A running venv interpreter is a pip-in-venv install.""" + monkeypatch.setattr(sys, "prefix", "/path/to/venv") + monkeypatch.setattr(sys, "base_prefix", "/usr/local") + assert detect(PurePosixPath("/path/to/venv/bin/python")) == "pip-venv" + + def test_tool_environments_win_over_their_venv( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """pipx, uv, and Nix environments are venvs too; the tool decides.""" + monkeypatch.setenv("VIRTUAL_ENV", "/home/user/project/.venv") + assert detect(PurePosixPath(PIPX_PYTHON)) == "pipx" + assert detect(PurePosixPath("/nix/store/abc-python3/bin/python3")) == "nix" + + def test_pip_installer_record(self) -> None: + """Outside any environment, pip's INSTALLER record identifies pip.""" + assert detect(PurePosixPath("/usr/bin/python3"), installer="pip") == "pip" + + def test_running_interpreter_and_metadata( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """detect_installation_method reads the interpreter and INSTALLER record.""" + monkeypatch.setattr(sys, "executable", PIPX_PYTHON) + assert detect_installation_method() == "pipx" + + class Distribution: + @staticmethod + def read_text(filename: str) -> str | None: + return "pip\n" if filename == "INSTALLER" else None + + monkeypatch.setattr(sys, "executable", "/usr/bin/python3") + monkeypatch.setattr( + install_method.importlib.metadata, + "distribution", + lambda _name: Distribution, + ) + assert detect_installation_method() == "pip" + + +class TestUpgradeCommand: + """The installer command for each method.""" + + @pytest.mark.parametrize( + ("method", "target_version", "expected"), + [ + ("pipx", None, "pipx upgrade kilm"), + ("pipx", "1.0.5", "pipx install --force kilm==1.0.5"), + # `uv tool upgrade` would keep an earlier exact pin. + ("uv", None, "uv tool install kilm@latest"), + ("uv", "1.0.5", "uv tool install kilm==1.0.5"), + ("pip", None, "pip install --upgrade kilm"), + ("pip-venv", "1.0.5", "pip install --upgrade kilm==1.0.5"), + ], + ) + def test_automatic_methods( + self, method: InstallMethod, target_version: str | None, expected: str + ) -> None: + """Automatic methods install the latest release or exactly the target.""" + assert update_instruction(method, target_version) == expected + + @pytest.mark.parametrize( + ("method", "expected"), + [ + ("conda", "Conda package not yet available (planned for future)"), + ("homebrew", "Homebrew package not yet available (planned for future)"), + ( + "nix", + ( + "nix flake update, then rebuild the configuration or profile" + " that installs kilm" + ), + ), + ("unknown", "Update KiLM with the tool that installed it"), + ], + ) + def test_manual_methods(self, method: InstallMethod, expected: str) -> None: + """Methods KiLM cannot update get advice instead of a command.""" + assert upgrade_command(method) is None + assert update_instruction(method) == expected + + +class TestRunUpgrade: + """Running an installer and explaining its failure.""" + + @pytest.fixture + def calls(self, monkeypatch: pytest.MonkeyPatch) -> list[list[str]]: + """Record installer runs; each fails with a long error output.""" + recorded: list[list[str]] = [] + + def fake_run( + argv: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + recorded.append(argv) + error = "".join(f"line {number}\n" for number in range(20)) + return subprocess.CompletedProcess(argv, 1, "", error) + + monkeypatch.setattr(install_method.subprocess, "run", fake_run) + return recorded + + def test_pip_runs_in_the_running_interpreter(self, calls: list[list[str]]) -> None: + """Pip updates the environment kilm runs in, not whatever pip is on PATH.""" + run_upgrade(["pip", "install", "--upgrade", "kilm"]) + assert calls == [[sys.executable, "-m", "pip", "install", "--upgrade", "kilm"]] + + def test_failure_reports_the_end_of_the_error_output( + self, calls: list[list[str]] + ) -> None: + """The last lines of the installer's output explain the failure.""" + error = run_upgrade(["pipx", "upgrade", "kilm"]) + assert calls == [["pipx", "upgrade", "kilm"]] + assert error == "\n".join(f"line {number}" for number in range(10, 20)) + + def test_missing_installer(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An installer that cannot start is reported, not raised.""" + + def fake_run(argv: list[str], **_kwargs: object) -> None: + raise FileNotFoundError(2, "No such file or directory", argv[0]) + + monkeypatch.setattr(install_method.subprocess, "run", fake_run) + error = run_upgrade(["pipx", "upgrade", "kilm"]) + assert error == "Could not run pipx: No such file or directory" + + def test_success(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A zero exit status is success.""" + + def fake_run( + argv: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(argv, 0, "done", "") + + monkeypatch.setattr(install_method.subprocess, "run", fake_run) + assert run_upgrade(["uv", "tool", "install", "kilm@latest"]) is None diff --git a/tests/test_isolation.py b/tests/test_isolation.py new file mode 100644 index 0000000..b202f3f --- /dev/null +++ b/tests/test_isolation.py @@ -0,0 +1,20 @@ +"""The suite runs CLI commands, which check for updates; keep them contained.""" + +import tempfile +from pathlib import Path + +import pytest +import requests + + +def test_home_is_a_temporary_directory() -> None: + """Config, cache, and KiCad files land in a per-test home, not the user's.""" + temp_root = Path(tempfile.gettempdir()).resolve() + + assert Path.home().resolve().is_relative_to(temp_root) + + +def test_network_requests_fail() -> None: + """The update check cannot reach PyPI, so results never depend on it.""" + with pytest.raises(requests.ConnectionError): + requests.get("https://pypi.org/pypi/kilm/json", timeout=5) diff --git a/tests/test_kicad_config.py b/tests/test_kicad_config.py new file mode 100644 index 0000000..c0cdf22 --- /dev/null +++ b/tests/test_kicad_config.py @@ -0,0 +1,68 @@ +"""Tests for locating KiCad's configuration directory.""" + +from pathlib import Path + +import pytest + +from kicad_lib_manager.services.kicad_config import find_kicad_config + + +def make_versions(root: Path, *versions: str) -> None: + """Create version directories under root, each with a symbol table.""" + for version in versions: + (root / version).mkdir(parents=True) + (root / version / "sym-lib-table").touch() + + +@pytest.mark.parametrize( + ("system", "relative"), + [("Darwin", "Library/Preferences/kicad"), ("Linux", ".config/kicad")], +) +def test_finds_latest_numeric_version( + tmp_path: Path, system: str, relative: str +) -> None: + """Versions order numerically, so 10.0 is newer than 9.0.""" + root = tmp_path / relative + make_versions(root, "8.0", "9.0", "10.0") + (root / "colors").mkdir() + + assert find_kicad_config(system=system, home=tmp_path) == root / "10.0" + + +def test_windows_uses_appdata(tmp_path: Path) -> None: + """On Windows the configuration lives under APPDATA.""" + make_versions(tmp_path / "kicad", "9.0") + + found = find_kicad_config( + system="Windows", home=tmp_path, environ={"APPDATA": str(tmp_path)} + ) + + assert found == tmp_path / "kicad" / "9.0" + + +@pytest.mark.parametrize( + ("system", "environ", "message"), + [ + ("Windows", {}, "APPDATA"), + ("Plan9", {}, "Unsupported platform"), + ("Linux", {}, "configuration directory not found"), + ], +) +def test_reports_missing_configuration( + tmp_path: Path, system: str, environ: dict[str, str], message: str +) -> None: + """Unsupported platforms and missing directories raise FileNotFoundError.""" + with pytest.raises(FileNotFoundError, match=message): + find_kicad_config(system=system, home=tmp_path, environ=environ) + + +def test_requires_version_directory_with_tables(tmp_path: Path) -> None: + """KiCad must have created a version directory with a library table.""" + root = tmp_path / ".config" / "kicad" + root.mkdir(parents=True) + with pytest.raises(FileNotFoundError, match="No KiCad version directories"): + find_kicad_config(system="Linux", home=tmp_path) + + (root / "9.0").mkdir() + with pytest.raises(FileNotFoundError, match="library tables not found"): + find_kicad_config(system="Linux", home=tmp_path) diff --git a/tests/test_leaf_command_usage.py b/tests/test_leaf_command_usage.py index cafdaa7..dda9190 100644 --- a/tests/test_leaf_command_usage.py +++ b/tests/test_leaf_command_usage.py @@ -31,6 +31,7 @@ def _registered_commands() -> Mapping[str, click.Command]: def _usage_line(command: str) -> str: + """Return the ``Usage:`` line of ``kilm --help``.""" result = CliRunner().invoke(app, [command, "--help"]) assert result.exit_code == 0, result.output return next(line for line in result.output.splitlines() if "Usage:" in line) @@ -38,6 +39,7 @@ def _usage_line(command: str) -> str: @pytest.mark.parametrize("command", LEAVES) def test_leaf_command_usage_has_no_subcommand_placeholder(command: str) -> None: + """A leaf command's usage line does not promise subcommands (issue #13).""" usage = _usage_line(command) assert "COMMAND" not in usage assert "[ARGS]" not in usage @@ -45,6 +47,7 @@ def test_leaf_command_usage_has_no_subcommand_placeholder(command: str) -> None: @pytest.mark.parametrize("command", GROUPS) def test_group_has_subcommands_and_keeps_placeholder(command: str) -> None: + """A real group has subcommands and shows the subcommand placeholder.""" group = COMMANDS[command] assert isinstance(group, click.Group) assert group.commands, f"{command} is a group without subcommands; use app.command" diff --git a/tests/test_library_manager.py b/tests/test_library_manager.py deleted file mode 100644 index 47bc964..0000000 --- a/tests/test_library_manager.py +++ /dev/null @@ -1,227 +0,0 @@ -import os -from pathlib import Path - -import pytest - -from kicad_lib_manager.services.library_service import LibraryService - - -def test_format_uri_absolute_path(): - """Test URI formatting with absolute paths.""" - # Test Unix-style paths - assert ( - LibraryService.format_uri("/path/to/lib", "test_lib", "symbols") - == "/path/to/lib/symbols/test_lib.kicad_sym" - ) - assert ( - LibraryService.format_uri("/path/to/lib", "test_lib", "footprints") - == "/path/to/lib/footprints/test_lib.pretty" - ) - - # Test Windows-style paths - assert ( - LibraryService.format_uri("C:\\path\\to\\lib", "test_lib", "symbols") - == "C:/path/to/lib/symbols/test_lib.kicad_sym" - ) - assert ( - LibraryService.format_uri("C:\\path\\to\\lib", "test_lib", "footprints") - == "C:/path/to/lib/footprints/test_lib.pretty" - ) - - -def test_format_uri_env_var(): - """Test URI formatting with environment variable names.""" - assert ( - LibraryService.format_uri("KICAD_LIB", "test_lib", "symbols") - == "${KICAD_LIB}/symbols/test_lib.kicad_sym" - ) - assert ( - LibraryService.format_uri("KICAD_LIB", "test_lib", "footprints") - == "${KICAD_LIB}/footprints/test_lib.pretty" - ) - - -def test_format_uri_path_in_curly(): - """Test URI formatting with paths already in ${} format.""" - # Test absolute paths in ${} - assert ( - LibraryService.format_uri("${/path/to/lib}", "test_lib", "symbols") - == "/path/to/lib/symbols/test_lib.kicad_sym" - ) - - # Test environment variables in ${} - assert ( - LibraryService.format_uri("${KICAD_LIB}", "test_lib", "symbols") - == "${KICAD_LIB}/symbols/test_lib.kicad_sym" - ) - - -def test_format_uri_edge_cases(): - """Test URI formatting with edge cases.""" - # Test with empty library name - assert ( - LibraryService.format_uri("/path/to/lib", "", "symbols") - == "/path/to/lib/symbols/.kicad_sym" - ) - - # Test with special characters in library name - assert ( - LibraryService.format_uri("/path/to/lib", "test-lib_123", "symbols") - == "/path/to/lib/symbols/test-lib_123.kicad_sym" - ) - - # Test with mixed slashes - assert ( - LibraryService.format_uri("C:/path\\to/lib", "test_lib", "symbols") - == "C:/path/to/lib/symbols/test_lib.kicad_sym" - ) - - # Test with UTF-8 characters in path - assert ( - LibraryService.format_uri("/path/to/šžć", "test_lib", "symbols") - == "/path/to/šžć/symbols/test_lib.kicad_sym" - ) - - # Test with UTF-8 characters and spaces in path - assert ( - LibraryService.format_uri("/path/to /šžć ", "test_lib", "symbols") - == "/path/to /šžć /symbols/test_lib.kicad_sym" - ) - - -def test_format_uri_invalid_input(): - """Test URI formatting with invalid inputs.""" - with pytest.raises(ValueError): - LibraryService.format_uri("", "test_lib", "symbols") # Empty base path - - with pytest.raises(ValueError): - LibraryService.format_uri( - "/path/to/lib", "test_lib", "invalid_type" - ) # Invalid library type - - with pytest.raises(ValueError): - LibraryService.format_uri("${unclosed", "test_lib", "symbols") # Unclosed ${ - - -def test_find_kicad_config_uses_latest_numeric_version(tmp_path, monkeypatch): - """Test KiCad config detection prefers numeric version order.""" - home_dir = tmp_path / "home" - config_dir = home_dir / "Library" / "Preferences" / "kicad" - config_dir.mkdir(parents=True) - - for version in ("8.0", "9.0", "10.0"): - version_dir = config_dir / version - version_dir.mkdir() - (version_dir / "sym-lib-table").touch() - - monkeypatch.setattr( - "kicad_lib_manager.services.library_service.platform.system", - lambda: "Darwin", - ) - monkeypatch.setattr( - "kicad_lib_manager.services.library_service.Path.home", - lambda: home_dir, - ) - - assert LibraryService.find_kicad_config() == config_dir / "10.0" - - -def test_add_libraries_integration(tmp_path): - """Test the full add_libraries function with different path formats.""" - # Create temporary directories - lib_dir = tmp_path / "lib" - config_dir = tmp_path / "config" - lib_dir.mkdir() - config_dir.mkdir() - - # Create test library structure - (lib_dir / "symbols").mkdir() - (lib_dir / "footprints").mkdir() - (lib_dir / "symbols" / "test_lib.kicad_sym").touch() - (lib_dir / "footprints" / "test_lib.pretty").touch() - - # Create LibraryService instance - service = LibraryService() - - # Test with absolute path - added_libs, changes = service.add_libraries(str(lib_dir), config_dir, dry_run=True) - assert "test_lib" in added_libs - assert changes - - # Test with environment variable path - we need to set up the environment variable first - os.environ["KICAD_LIB"] = str(lib_dir) - added_libs, changes = service.add_libraries("KICAD_LIB", config_dir, dry_run=True) - assert "test_lib" in added_libs - assert changes - - # Test with path in ${} - use a proper environment variable name - os.environ["TEST_LIB"] = str(lib_dir) - added_libs, changes = service.add_libraries("${TEST_LIB}", config_dir, dry_run=True) - assert "test_lib" in added_libs - assert changes - - -def test_add_libraries_utf8(tmp_path): - """Test add_libraries with UTF-8 characters in paths and filenames.""" - # Create temporary directories with UTF-8 characters - lib_dir = tmp_path / "lib_žćš" - config_dir = tmp_path / "config_žćš" - lib_dir.mkdir() - config_dir.mkdir() - - # Create test library structure with UTF-8 characters - (lib_dir / "symbols").mkdir() - (lib_dir / "footprints").mkdir() - (lib_dir / "symbols" / "test_čš.kicad_sym").touch() - ( - lib_dir / "footprints" / "test_šž.pretty" - ).mkdir() # Footprint libs are directories - - # Create LibraryService instance - service = LibraryService() - - # Test adding libraries with UTF-8 paths/names - added_libs, changes = service.add_libraries(str(lib_dir), config_dir, dry_run=True) - - # Assert that the libraries with UTF-8 names were detected - assert "test_čš" in added_libs - assert "test_šž" in added_libs - assert changes - - -def test_add_entries_with_special_chars(tmp_path): - """Test adding entries with special characters in paths.""" - # Create a temporary library table - table_path = tmp_path / "fp-lib-table" - with Path(table_path).open("w", encoding="utf-8") as f: - f.write("(fp_lib_table\n (version 7)\n)\n") - - # Test entries with special characters - entries = [ - { - "name": "TestLib1", - "uri": "${/Users/test/žćš/footprints.pretty}", - "options": "", - "description": "Test library with special chars", - }, - { - "name": "TestLib2", - "uri": "${/Users/test/žćš/symbols.kicad_sym}", - "options": "", - "description": "Another test library", - }, - ] - - # Add entries - LibraryService.add_entries_to_table(table_path, entries) - - # Read the updated table - with Path(table_path).open(encoding="utf-8") as f: - content = f.read() - - # Verify the entries were added correctly - assert "TestLib1" in content - assert "TestLib2" in content - assert "/Users/test/žćš/footprints.pretty" in content - assert "/Users/test/žćš/symbols.kicad_sym" in content - assert "žćš" in content # Verify special characters are preserved diff --git a/tests/test_library_service.py b/tests/test_library_service.py new file mode 100644 index 0000000..11b842d --- /dev/null +++ b/tests/test_library_service.py @@ -0,0 +1,89 @@ +"""Tests for creating KiLM libraries.""" + +from pathlib import Path + +import pytest +import yaml + +from kicad_lib_manager.services import library_service +from kicad_lib_manager.services.library_service import LibraryService +from kicad_lib_manager.utils.metadata import read_github_metadata, write_github_metadata + +ALL_CAPABILITIES = {"symbols": True, "footprints": True, "templates": True} + + +def test_new_library_gets_folders_and_metadata(tmp_path: Path) -> None: + """A new library is named as given, with a variable derived from the name.""" + metadata = LibraryService.initialize_library( + tmp_path, name="My Parts", description="Shared parts" + ) + + assert all((tmp_path / folder).is_dir() for folder in ALL_CAPABILITIES) + assert metadata["name"] == "My Parts" + assert metadata["description"] == "Shared parts" + assert metadata["env_var"] == "KICAD_LIB_MY_PARTS" + assert metadata["capabilities"] == ALL_CAPABILITIES + assert read_github_metadata(tmp_path) == metadata + + +def test_new_library_without_environment_variable(tmp_path: Path) -> None: + """no_env_var leaves the variable out of new metadata.""" + metadata = LibraryService.initialize_library(tmp_path, no_env_var=True) + + assert "env_var" not in metadata + assert metadata["name"] == tmp_path.name + + +def test_existing_metadata_is_kept_with_overrides(tmp_path: Path) -> None: + """Existing fields survive; given ones replace them and mark the update.""" + write_github_metadata( + tmp_path, + {"name": "old", "version": "2.0.0", "env_var": "OLD", "created_with": "me"}, + ) + + metadata = LibraryService.initialize_library(tmp_path, env_var="NEW_VAR") + + assert metadata["name"] == "old" + assert metadata["version"] == "2.0.0" + assert metadata["env_var"] == "NEW_VAR" + assert metadata["updated_with"] == "kilm" + assert read_github_metadata(tmp_path) == metadata + + +def test_init_keeps_entries_the_typed_metadata_cannot_hold(tmp_path: Path) -> None: + """Non-string keys and mistyped fields survive; a refused variable goes.""" + (tmp_path / "kilm.yaml").write_text( + "2026: retained\nname: old\nversion: 2\nenv_var: 5\n", encoding="utf-8" + ) + + LibraryService.initialize_library(tmp_path, no_env_var=True) + + written = yaml.safe_load((tmp_path / "kilm.yaml").read_text(encoding="utf-8")) + assert written[2026] == "retained" + assert written["name"] == "old" + assert written["version"] == 2 + assert "env_var" not in written + + +def test_force_replaces_existing_metadata(tmp_path: Path) -> None: + """With force, the metadata starts fresh.""" + write_github_metadata(tmp_path, {"name": "old", "version": "2.0.0"}) + + metadata = LibraryService.initialize_library(tmp_path, force=True) + + assert metadata["name"] == tmp_path.name + assert metadata["version"] == "1.0.0" + + +def test_unwritable_metadata_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A metadata file that cannot be written is an error, not a silent success.""" + monkeypatch.setattr( + library_service, + "write_github_metadata", + lambda _directory, _metadata, _unsupported: False, + ) + + with pytest.raises(OSError, match=r"kilm\.yaml"): + LibraryService.initialize_library(tmp_path) diff --git a/tests/test_library_table.py b/tests/test_library_table.py new file mode 100644 index 0000000..7d313ee --- /dev/null +++ b/tests/test_library_table.py @@ -0,0 +1,243 @@ +"""Tests for adding KiLM libraries to KiCad's library tables.""" + +import sys +from pathlib import Path + +import pytest + +from kicad_lib_manager.services.library_table import ( + DESCRIPTIONS_FILE, + TableEntry, + add_entries_to_table, + add_libraries, + format_uri, +) +from kicad_lib_manager.utils.file_ops import list_configured_libraries + +EMPTY_TABLES = { + "sym-lib-table": "(sym_lib_table\n (version 7)\n)\n", + "fp-lib-table": "(fp_lib_table\n (version 7)\n)\n", +} + + +@pytest.mark.parametrize( + ("base_path", "lib_type", "expected"), + [ + ("/path/to/lib", "symbols", "/path/to/lib/symbols/test_lib.kicad_sym"), + ("/path/to/lib", "footprints", "/path/to/lib/footprints/test_lib.pretty"), + ("C:\\path\\to\\lib", "symbols", "C:/path/to/lib/symbols/test_lib.kicad_sym"), + ("C:/path\\to/lib", "footprints", "C:/path/to/lib/footprints/test_lib.pretty"), + ("KICAD_LIB", "symbols", "${KICAD_LIB}/symbols/test_lib.kicad_sym"), + ("${KICAD_LIB}", "footprints", "${KICAD_LIB}/footprints/test_lib.pretty"), + ("${/path/to/lib}", "symbols", "/path/to/lib/symbols/test_lib.kicad_sym"), + ("/path/to /šžć ", "symbols", "/path/to /šžć /symbols/test_lib.kicad_sym"), + ], +) +def test_format_uri(base_path: str, lib_type: str, expected: str) -> None: + """Absolute paths stay paths with forward slashes; other bases are variables.""" + assert format_uri(base_path, "test_lib", lib_type) == expected + + +@pytest.mark.parametrize( + ("base_path", "lib_type"), + [("", "symbols"), ("/path/to/lib", "invalid_type"), ("${unclosed", "symbols")], +) +def test_format_uri_rejects_invalid_input(base_path: str, lib_type: str) -> None: + """An empty base, unknown kind, or unclosed variable is rejected.""" + with pytest.raises(ValueError, match=r"empty|Invalid"): + format_uri(base_path, "test_lib", lib_type) + + +def write_table(path: Path) -> Path: + """Write an empty library table of the kind path's name says.""" + path.write_text(EMPTY_TABLES[path.name], encoding="utf-8") + return path + + +def test_add_entries_keeps_utf8_and_unwraps_absolute_paths(tmp_path: Path) -> None: + """Entries go before the closing parenthesis with `${/abs}` unwrapped.""" + table = write_table(tmp_path / "fp-lib-table") + + add_entries_to_table( + table, + [ + TableEntry("TestLib1", "${/Users/test/žćš/a.pretty}", "First"), + TableEntry("TestLib2", "/Users/test/b.pretty", "Second"), + ], + ) + + assert table.read_text(encoding="utf-8") == ( + "(fp_lib_table\n" + " (version 7)\n" + ' (lib (name "TestLib1")(type "KiCad")(uri "/Users/test/žćš/a.pretty")' + '(options "")(descr "First"))\n' + ' (lib (name "TestLib2")(type "KiCad")(uri "/Users/test/b.pretty")' + '(options "")(descr "Second"))\n' + ")\n" + ) + + +def test_add_entries_escapes_quotes_and_backslashes(tmp_path: Path) -> None: + """Quotes, backslashes, and newlines are escaped as KiCad writes them.""" + table = write_table(tmp_path / "sym-lib-table") + + add_entries_to_table( + table, [TableEntry("Quoted", "C:\\libs\\a.kicad_sym", 'The "main"\nparts')] + ) + + content = table.read_text(encoding="utf-8") + assert '(uri "C:\\\\libs\\\\a.kicad_sym")' in content + assert '(descr "The \\"main\\"\\nparts")' in content + + +def test_written_entries_read_back_unchanged(tmp_path: Path) -> None: + """Escaped names, URIs, and descriptions decode to what was written.""" + table = write_table(tmp_path / "sym-lib-table") + entries = [ + TableEntry("back\\slash", "C:\\libs\\a.kicad_sym", 'The "main"\nparts\\t'), + TableEntry('quote"d (name "x")', "/žćš/b.kicad_sym", "Ünïcode"), + ] + + add_entries_to_table(table, entries) + + symbols, _footprints = list_configured_libraries(tmp_path) + assert symbols == [ + {"name": e.name, "type": "KiCad", "uri": e.uri, "description": e.description} + for e in entries + ] + + +def test_reader_decodes_strings_as_kicad_does(tmp_path: Path) -> None: + """Unquoted atoms, hex and octal bytes, and unknown escapes read like KiCad.""" + (tmp_path / "sym-lib-table").write_text( + "(sym_lib_table\n" + ' (lib (name "\\x41\\101\\305\\276\\q\\8")(type Legacy)(uri ${X}/a.lib)' + '(descr "tab\\there"))\n' + ")\n", + encoding="utf-8", + ) + + symbols, _footprints = list_configured_libraries(tmp_path) + + assert symbols == [ + { + "name": "AAž\\q\\8", + "type": "Legacy", + "uri": "${X}/a.lib", + "description": "tab\there", + } + ] + + +def test_add_entries_requires_an_existing_table(tmp_path: Path) -> None: + """A missing table is an error rather than created.""" + with pytest.raises(ValueError, match="not found"): + add_entries_to_table(tmp_path / "sym-lib-table", []) + + +@pytest.fixture +def library(tmp_path: Path) -> Path: + """A KiLM library with a UTF-8 named symbol and footprint library.""" + library = tmp_path / "lib_žćš" + (library / "symbols").mkdir(parents=True) + (library / "footprints" / "test_šž.pretty").mkdir(parents=True) + (library / "symbols" / "test_čš.kicad_sym").touch() + return library + + +@pytest.fixture +def kicad_config(tmp_path: Path) -> Path: + """A KiCad configuration directory with empty library tables.""" + config = tmp_path / "config" + config.mkdir() + for name in EMPTY_TABLES: + write_table(config / name) + return config + + +def test_add_libraries_writes_new_entries_once( + library: Path, kicad_config: Path +) -> None: + """New libraries are added with described entries; known ones are skipped.""" + (library / DESCRIPTIONS_FILE).write_text( + "symbols:\n test_čš: Hand-made symbols\n", encoding="utf-8" + ) + + added, changed = add_libraries(str(library), kicad_config) + + assert added == {"test_čš", "test_šž"} + assert changed + symbols, footprints = list_configured_libraries(kicad_config) + assert symbols == [ + { + "name": "test_čš", + "type": "KiCad", + "uri": f"{library.as_posix()}/symbols/test_čš.kicad_sym", + "description": "Hand-made symbols", + } + ] + assert footprints[0]["description"] == "test_šž footprint library" + assert add_libraries(str(library), kicad_config) == (set(), False) + + +@pytest.mark.skipif(sys.platform == "win32", reason="names a file with a backslash") +def test_backslash_named_library_is_added_once( + tmp_path: Path, kicad_config: Path +) -> None: + """A second setup finds the escaped entry and adds no duplicate.""" + library = tmp_path / "lib" + (library / "symbols").mkdir(parents=True) + (library / "symbols" / "a\\b.kicad_sym").touch() + + assert add_libraries(str(library), kicad_config) == ({"a\\b"}, True) + assert add_libraries(str(library), kicad_config) == (set(), False) + symbols, _footprints = list_configured_libraries(kicad_config) + assert [entry["name"] for entry in symbols] == ["a\\b"] + + +@pytest.mark.parametrize("reference", ["KICAD_LIB", "${KICAD_LIB}"]) +def test_add_libraries_resolves_environment_variables( + library: Path, + kicad_config: Path, + monkeypatch: pytest.MonkeyPatch, + reference: str, +) -> None: + """A variable name, bare or in `${}`, names the library directory.""" + monkeypatch.setenv("KICAD_LIB", str(library)) + + added, changed = add_libraries(reference, kicad_config, dry_run=True) + + assert added == {"test_čš", "test_šž"} + assert changed + assert list_configured_libraries(kicad_config) == ([], []) + + +def test_add_libraries_rejects_unset_variable( + kicad_config: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An unset variable is reported by name.""" + monkeypatch.delenv("KILM_UNSET", raising=False) + + with pytest.raises(FileNotFoundError, match="KILM_UNSET"): + add_libraries("KILM_UNSET", kicad_config) + + +def test_add_libraries_checks_3d_directories( + library: Path, kicad_config: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A missing main 3D directory fails; missing additional ones only warn.""" + missing = str(library / "missing") + + with pytest.raises(FileNotFoundError, match="3D models directory"): + add_libraries(str(library), kicad_config, kicad_3d_dir=missing) + + add_libraries( + str(library), kicad_config, additional_3d_dirs={"EXTRA": missing}, dry_run=True + ) + assert "3D models directory not found" in capsys.readouterr().err + + +def test_add_libraries_requires_libraries(tmp_path: Path, kicad_config: Path) -> None: + """A directory without symbols or footprints is rejected.""" + with pytest.raises(ValueError, match="No libraries found"): + add_libraries(str(tmp_path), kicad_config) diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 0000000..84a9586 --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,84 @@ +"""Tests for reading and writing library metadata files.""" + +from pathlib import Path + +import pytest + +from kicad_lib_manager.utils.metadata import ( + generate_env_var_name, + read_cloud_metadata, + read_github_metadata, + write_cloud_metadata, + write_github_metadata, +) +from tests.fault_injection import fail_writes + + +def test_known_fields_are_checked_and_unknown_fields_kept(tmp_path: Path) -> None: + """A known field of the wrong type reads as absent; other fields survive.""" + (tmp_path / "kilm.yaml").write_text( + "name: mylib\nversion: 2\nenv_var: null\nmaintainer: Ada\n", + encoding="utf-8", + ) + + metadata = read_github_metadata(tmp_path) + + assert metadata == {"name": "mylib", "env_var": None, "maintainer": "Ada"} + + +def test_round_trip_keeps_fields_kilm_does_not_know(tmp_path: Path) -> None: + """Changing a field and writing back keeps what other tools added.""" + (tmp_path / "kilm.yaml").write_text( + "name: old\nmaintainer: Ada\n", encoding="utf-8" + ) + metadata = read_github_metadata(tmp_path) + assert metadata is not None + + metadata["name"] = "new" + assert write_github_metadata(tmp_path, metadata) + + assert read_github_metadata(tmp_path) == {"name": "new", "maintainer": "Ada"} + + +def test_failed_write_keeps_the_old_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A write that fails part way leaves the previous metadata in place.""" + (tmp_path / "kilm.yaml").write_text("name: old\n", encoding="utf-8") + (tmp_path / ".kilm_metadata").write_text('{"name": "old"}', encoding="utf-8") + fail_writes(monkeypatch) + + assert not write_github_metadata(tmp_path, {"name": "new"}) + assert not write_cloud_metadata(tmp_path, {"name": "new"}) + + monkeypatch.undo() + assert read_github_metadata(tmp_path) == {"name": "old"} + assert read_cloud_metadata(tmp_path) == {"name": "old"} + + +def test_missing_empty_and_unreadable_files( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Missing reads as None, a non-mapping as empty, invalid JSON as None.""" + assert read_cloud_metadata(tmp_path) is None + + (tmp_path / ".kilm_metadata").write_text("[1, 2]", encoding="utf-8") + assert read_cloud_metadata(tmp_path) == {} + + (tmp_path / ".kilm_metadata").write_text("{not json", encoding="utf-8") + assert read_cloud_metadata(tmp_path) is None + assert "Error reading cloud metadata file" in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("name", "prefix", "expected"), + [ + ("my-lib 2", "KICAD_LIB", "KICAD_LIB_MY_LIB_2"), + ("3d models", "", "LIB_3D_MODELS"), + ("", "", "LIB_"), + ("x" * 60, "KICAD_3D", "KICAD_3D_" + "X" * 41), + ], +) +def test_generate_env_var_name(name: str, prefix: str, expected: str) -> None: + """Names are upper case, start with a letter, and stay within 50 chars.""" + assert generate_env_var_name(name, prefix) == expected diff --git a/tests/test_pypi_version.py b/tests/test_pypi_version.py new file mode 100644 index 0000000..56dcd1b --- /dev/null +++ b/tests/test_pypi_version.py @@ -0,0 +1,124 @@ +"""Tests for the cached PyPI latest-release lookup.""" + +import json +import time +from collections.abc import Iterator +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +import requests + +from kicad_lib_manager.services.pypi_version import ( + CACHE_TTL_SECONDS, + PyPIVersionChecker, +) + + +@pytest.fixture +def cache_file(tmp_path: Path) -> Path: + """A cache location inside the test's temporary directory.""" + return tmp_path / "cache" / "version_check.json" + + +@pytest.fixture +def checker(cache_file: Path) -> PyPIVersionChecker: + """A checker whose cache lives in the test's temporary directory.""" + return PyPIVersionChecker("kilm", "1.0.0", cache_file=cache_file) + + +def pypi_response(version: str = "1.1.0", status_code: int = 200) -> Mock: + """A PyPI JSON API response announcing `version`.""" + response = Mock() + response.status_code = status_code + response.json.return_value = {"info": {"version": version}} + response.headers.get.return_value = "etag123" + return response + + +@pytest.fixture +def pypi_get() -> Iterator[Mock]: + """Patch the PyPI request so no test reaches the network.""" + with patch("requests.get") as mock_get: + mock_get.return_value = pypi_response() + yield mock_get + + +def test_latest_version_from_pypi(checker: PyPIVersionChecker, pypi_get: Mock) -> None: + """The version comes from PyPI's JSON API with an identifying User-Agent.""" + assert checker.check_latest_version() == "1.1.0" + assert pypi_get.call_args.args == ("https://pypi.org/pypi/kilm/json",) + assert pypi_get.call_args.kwargs["headers"]["User-Agent"].startswith("KiLM/1.0.0 ") + + +def test_network_error_is_unknown(checker: PyPIVersionChecker, pypi_get: Mock) -> None: + """A failed request means the latest version is unknown; nothing raises.""" + pypi_get.side_effect = requests.RequestException("Network error") + assert checker.check_latest_version() is None + + +def test_unexpected_response_is_unknown( + checker: PyPIVersionChecker, pypi_get: Mock +) -> None: + """Error statuses and bodies without a version are not versions.""" + pypi_get.return_value = pypi_response(status_code=503) + assert checker.check_latest_version() is None + pypi_get.return_value.status_code = 200 + pypi_get.return_value.json.return_value = {"info": None} + assert checker.check_latest_version() is None + for body in ([], None): + pypi_get.return_value.json.return_value = body + assert checker.check_latest_version() is None + + +def test_fresh_cache_answers_without_network( + checker: PyPIVersionChecker, pypi_get: Mock +) -> None: + """Within a day of the last check the cached version is the answer.""" + checker.check_latest_version() + pypi_get.reset_mock() + + assert checker.check_latest_version() == "1.1.0" + pypi_get.assert_not_called() + + +def test_bypassing_the_cache_queries_pypi( + checker: PyPIVersionChecker, pypi_get: Mock +) -> None: + """use_cache=False asks PyPI even when the cache is fresh.""" + checker.check_latest_version() + pypi_get.return_value = pypi_response("1.2.0") + + assert checker.check_latest_version(use_cache=False) == "1.2.0" + assert checker.check_latest_version() == "1.2.0" + + +def test_stale_cache_is_revalidated_with_its_etag( + checker: PyPIVersionChecker, cache_file: Path, pypi_get: Mock +) -> None: + """After a day PyPI is asked again; a 304 keeps and renews the answer.""" + stale = time.time() - CACHE_TTL_SECONDS - 1 + cache_file.parent.mkdir() + cache_file.write_text( + json.dumps({"version": "1.0.5", "etag": "etag123", "timestamp": stale}), + encoding="utf-8", + ) + pypi_get.return_value = pypi_response(status_code=304) + + assert checker.check_latest_version() == "1.0.5" + assert pypi_get.call_args.kwargs["headers"]["If-None-Match"] == "etag123" + pypi_get.reset_mock() + assert checker.check_latest_version() == "1.0.5" + pypi_get.assert_not_called() + + +@pytest.mark.usefixtures("pypi_get") +def test_unreadable_cache_is_ignored( + checker: PyPIVersionChecker, cache_file: Path +) -> None: + """A corrupt cache file is replaced by the next answer from PyPI.""" + cache_file.parent.mkdir() + cache_file.write_text("{not json", encoding="utf-8") + + assert checker.check_latest_version() == "1.1.0" + assert json.loads(cache_file.read_text(encoding="utf-8"))["version"] == "1.1.0" diff --git a/tests/test_repository_limits.py b/tests/test_repository_limits.py new file mode 100644 index 0000000..d4ce1ce --- /dev/null +++ b/tests/test_repository_limits.py @@ -0,0 +1,75 @@ +"""Repository limits that no lint rule enforces. + +- Every Python module stays within ``MAX_MODULE_LINES`` lines; split a module + that grows past it instead of raising the limit. +- Every lint or type suppression names its rule and states a reason after + ``--``, for example ``# noqa: S603 -- arguments are a fixed git command``. +- No module contains emoji or check-mark glyphs; CLI output uses words. +""" + +import io +import re +import tokenize +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PYTHON_ROOTS = ("kicad_lib_manager", "tests", ".github/scripts") +MAX_MODULE_LINES = 600 + +SUPPRESSION = re.compile(r"\b(noqa|ty:\s*ignore|type:\s*ignore)\b") +REASONED_SUPPRESSION = re.compile( + r"#\s*(noqa: [A-Z]+[0-9]+(, [A-Z]+[0-9]+)*|ty: ignore\[[a-z-]+(, [a-z-]+)*\])" + r" -- \S" +) +# Miscellaneous symbols, dingbats (check and cross marks), pictographs, and +# the emoji variation selector. Arrows, bullets, and box drawing stay allowed. +PICTOGRAPH = re.compile("[\u2600-\u27bf\u2b00-\u2bff\ufe0f\U0001f000-\U0001faff]") + + +def python_modules() -> list[Path]: + """All Python modules under the checked roots, sorted for stable output.""" + return sorted( + path for root in PYTHON_ROOTS for path in (REPO_ROOT / root).rglob("*.py") + ) + + +def relative(path: Path) -> str: + """The repository-relative POSIX path, identical on every OS.""" + return path.relative_to(REPO_ROOT).as_posix() + + +def test_modules_stay_within_line_limit() -> None: + """No module exceeds MAX_MODULE_LINES lines.""" + oversized = { + relative(path): count + for path in python_modules() + if (count := len(path.read_text(encoding="utf-8").splitlines())) + > MAX_MODULE_LINES + } + assert not oversized, f"Modules over {MAX_MODULE_LINES} lines: {oversized}" + + +def test_suppressions_name_rule_and_reason() -> None: + """Every noqa or ty: ignore comment names its rule and gives a reason.""" + unreasoned = [] + for path in python_modules(): + source = path.read_text(encoding="utf-8") + for token in tokenize.generate_tokens(io.StringIO(source).readline): + if token.type != tokenize.COMMENT or not SUPPRESSION.search(token.string): + continue + if not REASONED_SUPPRESSION.search(token.string): + unreasoned.append(f"{relative(path)}:{token.start[0]}") + assert not unreasoned, f"Suppressions without rule and reason: {unreasoned}" + + +def test_modules_contain_no_pictographs() -> None: + """No module contains an emoji, check mark, or other pictograph.""" + found = [ + f"{relative(path)}:{number}" + for path in python_modules() + for number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ) + if PICTOGRAPH.search(line) + ] + assert not found, f"Pictographs in: {found}" diff --git a/tests/test_setup_command.py b/tests/test_setup_command.py new file mode 100644 index 0000000..2db98ae --- /dev/null +++ b/tests/test_setup_command.py @@ -0,0 +1,109 @@ +"""Tests for ``kilm setup`` against a temporary KiCad configuration.""" + +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from kicad_lib_manager.commands.setup import changes as setup_changes +from kicad_lib_manager.commands.setup import sources as setup_sources +from kicad_lib_manager.main import app +from kicad_lib_manager.services.library_service import LibraryService +from tests.fault_injection import fail_writes + +EMPTY_SYM_TABLE = "(sym_lib_table\n (version 7)\n)\n" +EMPTY_FP_TABLE = "(fp_lib_table\n (version 7)\n)\n" +INVALID_URI_TABLE = ( + "(sym_lib_table\n" + ' (lib (name "Bad")(type "KiCad")(uri "${/libs}/symbols/Bad.kicad_sym")' + '(options "")(descr ""))\n' + ")\n" +) + + +@pytest.fixture +def kicad_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Return an isolated KiCad configuration directory that setup finds.""" + monkeypatch.setattr(setup_sources, "find_library_variable", lambda _name: None) + config = tmp_path / "kicad" + config.mkdir() + (config / "sym-lib-table").write_text(EMPTY_SYM_TABLE, encoding="utf-8") + (config / "fp-lib-table").write_text(EMPTY_FP_TABLE, encoding="utf-8") + (config / "kicad_common.json").write_text("{}", encoding="utf-8") + monkeypatch.setattr( + LibraryService, "find_kicad_config", staticmethod(lambda: config) + ) + return config + + +@pytest.fixture +def library(tmp_path: Path) -> Path: + """Return a library directory with one symbol and one footprint library.""" + library = tmp_path / "library" + (library / "symbols").mkdir(parents=True) + (library / "symbols" / "Demo.kicad_sym").write_text("", encoding="utf-8") + (library / "footprints" / "Demo.pretty").mkdir(parents=True) + return library + + +def test_setup_backs_up_library_tables_before_changing_them( + kicad_config: Path, library: Path +) -> None: + """The backup holds the table as it was before setup added libraries.""" + result = CliRunner().invoke( + app, ["setup", "--kicad-lib-dir", str(library), "--no-pin-libraries"] + ) + + assert result.exit_code == 0, result.output + assert '(name "Demo")' in (kicad_config / "sym-lib-table").read_text( + encoding="utf-8" + ) + backups = sorted(kicad_config.glob("sym-lib-table.backup.*")) + assert [b.read_text(encoding="utf-8") for b in backups] == [EMPTY_SYM_TABLE] + + +def test_fix_invalid_uris_rewrites_wrapped_paths(kicad_config: Path) -> None: + """A path wrapped in ``${}`` becomes a plain path.""" + table = kicad_config / "sym-lib-table" + table.write_text(INVALID_URI_TABLE, encoding="utf-8") + + assert setup_changes.fix_invalid_uris(kicad_config) + + assert '(uri "/libs/symbols/Bad.kicad_sym")' in table.read_text(encoding="utf-8") + + +def test_fix_invalid_uris_keeps_the_table_when_writing_fails( + kicad_config: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed write leaves the original table in place.""" + table = kicad_config / "sym-lib-table" + table.write_text(INVALID_URI_TABLE, encoding="utf-8") + fail_writes(monkeypatch) + + with pytest.raises(OSError, match="No space left"): + setup_changes.fix_invalid_uris(kicad_config) + + assert table.read_text(encoding="utf-8") == INVALID_URI_TABLE + + +def test_setup_keeps_max_backups_when_pinning( + kicad_config: Path, library: Path +) -> None: + """Pinning prunes ``kicad_common.json`` backups to ``--max-backups``.""" + kicad_common = kicad_config / "kicad_common.json" + current_vars = {"KICAD_USER_LIB": library.as_posix()} + kicad_common.write_text( + json.dumps({"environment": {"vars": current_vars}}), encoding="utf-8" + ) + for second in range(3): + old_backup = kicad_config / f"kicad_common.json.backup.20000101_00000{second}" + old_backup.write_text("{}", encoding="utf-8") + + result = CliRunner().invoke( + app, ["setup", "--kicad-lib-dir", str(library), "--max-backups", "1"] + ) + + assert result.exit_code == 0, result.output + assert "Pinned 1 symbol and 1 footprint libraries" in result.output + assert len(list(kicad_config.glob("kicad_common.json.backup.*"))) == 1 diff --git a/tests/test_startup_imports.py b/tests/test_startup_imports.py new file mode 100644 index 0000000..a83e416 --- /dev/null +++ b/tests/test_startup_imports.py @@ -0,0 +1,32 @@ +"""Startup cost: heavy dependencies load on first use, not at import.""" + +import subprocess +import sys + +import pytest + +# Each is listed in __lazy_modules__ by the modules that import it. +LAZY_MODULES = ( + "jinja2", + "packaging.version", + "pathspec", + "questionary", + "requests", + "yaml", +) + + +@pytest.mark.skipif( + sys.version_info < (3, 15), reason="__lazy_modules__ (PEP 810) needs Python 3.15" +) +def test_importing_the_cli_loads_no_heavy_dependency() -> None: + """A module-level use of a lazy import would load it for every command.""" + code = ( + "import sys, kicad_lib_manager.main\n" + f"print(*[m for m in {LAZY_MODULES!r} if m in sys.modules])" + ) + result = subprocess.run( # noqa: S603 -- runs this interpreter on a fixed snippet + [sys.executable, "-c", code], capture_output=True, text=True, check=True + ) + + assert result.stdout.split() == [] diff --git a/tests/test_sync_command.py b/tests/test_sync_command.py index 1fd952e..4a8125a 100644 --- a/tests/test_sync_command.py +++ b/tests/test_sync_command.py @@ -1,259 +1,200 @@ -""" -Tests for KiCad Library Manager sync command (formerly update command). -""" +"""Tests for ``kilm sync`` against real git repositories in a temporary directory.""" +import shutil +import subprocess from pathlib import Path -from unittest.mock import MagicMock, patch import pytest from typer.testing import CliRunner +from kicad_lib_manager.commands.setup import sources as setup_sources +from kicad_lib_manager.commands.sync import command as sync_command from kicad_lib_manager.commands.sync.command import check_for_library_changes -from kicad_lib_manager.main import app as main +from kicad_lib_manager.main import app +from kicad_lib_manager.services.config_service import LibraryDict -# Sample test libraries -TEST_LIBRARIES = [ - {"name": "test-lib", "path": "/path/to/test/library", "type": "github"} -] +GIT = shutil.which("git") +pytestmark = pytest.mark.skipif(GIT is None, reason="git is not installed") -@pytest.fixture -def mock_config(monkeypatch): - """Mock configuration with test libraries.""" - config_mock = MagicMock() - config_mock.get_libraries.return_value = TEST_LIBRARIES - monkeypatch.setattr( - "kicad_lib_manager.commands.sync.command.Config", lambda: config_mock +def _git(repo: Path, *args: str) -> str: + assert GIT is not None + result = subprocess.run( # noqa: S603 -- fixed git arguments built by the tests + [GIT, *args], cwd=repo, capture_output=True, text=True, check=True ) - return config_mock + return result.stdout -@pytest.fixture -def mock_subprocess_run(monkeypatch): - """Mock subprocess.run to simulate git pull and git diff results.""" - run_mock = MagicMock() +def _commit_file(repo: Path, relative: str, message: str) -> None: + path = repo / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(message, encoding="utf-8") + _git(repo, "add", "--all") + _git(repo, "commit", "--quiet", "--message", message) + - # Create different results for different calls - def mock_run_side_effect(*args, **kwargs): - result = MagicMock() - result.returncode = 0 +class FakeConfig: + """Stands in for ``Config`` with a fixed list of GitHub libraries.""" - # Check if this is a git diff call - if args[0] == ["git", "diff", "--name-status", "HEAD~1", "HEAD"]: - result.stdout = "A\tsymbols/newlib.kicad_sym" - else: - # This is a git pull call - result.stdout = "Updating abcd123..efgh456\nsymbols/newlib.kicad_sym | 120 ++++++++++++\n1 file changed" + def __init__(self, libraries: list[LibraryDict]) -> None: + """Serve ``libraries`` from ``get_libraries``.""" + self.libraries = libraries - return result + def get_libraries(self, library_type: str | None = None) -> list[LibraryDict]: + """Return the configured libraries of ``library_type``.""" + return [lib for lib in self.libraries if lib["type"] == library_type] - run_mock.side_effect = mock_run_side_effect - monkeypatch.setattr("subprocess.run", run_mock) - return run_mock + +@pytest.fixture(autouse=True) +def isolated_git(home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep git away from the user's configuration and its output in English.""" + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(home / ".gitconfig")) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + monkeypatch.setenv("LC_ALL", "C") + for role in ("AUTHOR", "COMMITTER"): + monkeypatch.setenv(f"GIT_{role}_NAME", "KiLM Test") + monkeypatch.setenv(f"GIT_{role}_EMAIL", "kilm@example.com") @pytest.fixture -def mock_path_methods(monkeypatch): - """Mock Path methods to simulate filesystem.""" - - # Mock Path.exists to return True for all paths to avoid "Path does not exist" errors - def mock_exists(self): - return True - - # Mock Path.is_dir to return True for directories - def mock_is_dir(self): - return True - - # Mock Path / operator to properly build paths - def mock_truediv(self, other): - return Path(f"{self}/{other}") - - # Mock glob to simulate finding library files - def mock_glob(self, pattern): - if "**/*.kicad_sym" in pattern: - mock_file = MagicMock() - mock_file.name = "test.kicad_sym" - return [mock_file] - elif "**/*.pretty" in pattern: - mock_dir = MagicMock() - mock_dir.name = "test.pretty" - mock_dir.is_dir.return_value = True - return [mock_dir] - elif "*" in pattern and "metadata.yaml" not in str(self): - mock_dir = MagicMock() - mock_dir.name = "template-dir" - mock_dir.__truediv__ = lambda self, other: Path(f"{self}/{other}") - mock_dir.exists = lambda: True - return [mock_dir] - return [] - - monkeypatch.setattr(Path, "exists", mock_exists) - monkeypatch.setattr(Path, "is_dir", mock_is_dir) - monkeypatch.setattr(Path, "__truediv__", mock_truediv) - monkeypatch.setattr(Path, "glob", mock_glob) - - -def test_sync_command(mock_config, mock_subprocess_run, mock_path_methods): - """Test the basic sync command.""" - runner = CliRunner() - result = runner.invoke(main, ["sync"]) +def upstream(tmp_path: Path) -> Path: + """Return a repository with one commit that a library clones.""" + repo = tmp_path / "upstream" + repo.mkdir() + _git(repo, "init", "--quiet") + _commit_file(repo, "README.md", "initial") + return repo - assert result.exit_code == 0 + +@pytest.fixture +def library(tmp_path: Path, upstream: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Return a clone of ``upstream`` registered as the only GitHub library.""" + clone = tmp_path / "library" + _git(tmp_path, "clone", "--quiet", str(upstream), str(clone)) + config = FakeConfig([{"name": "demo", "path": str(clone), "type": "github"}]) + monkeypatch.setattr(sync_command, "Config", lambda: config) + return clone + + +@pytest.mark.usefixtures("library") +def test_sync_reports_an_up_to_date_library() -> None: + """A pull that brings nothing counts as up to date.""" + result = CliRunner().invoke(app, ["sync"]) + + assert result.exit_code == 0, result.output assert "Syncing 1 KiCad GitHub libraries" in result.output - assert "Updated" in result.output - assert "1 libraries synced" in result.output + assert "0 libraries synced" in result.output + assert "1 libraries up to date" in result.output + assert "No new libraries detected" in result.output - # Verify subprocess was called correctly - # Should be called twice: once for git pull, once for git diff - assert mock_subprocess_run.call_count == 2 - # Check the first call (git pull) - first_call_args, first_call_kwargs = mock_subprocess_run.call_args_list[0] - assert first_call_args[0] == ["git", "pull"] - assert first_call_kwargs["check"] is False +def test_sync_pulls_and_detects_new_libraries(upstream: Path, library: Path) -> None: + """New symbol libraries anywhere in the pulled commits are reported.""" + _commit_file(upstream, "symbols/New.kicad_sym", "add symbols") + _commit_file(upstream, "docs/notes.md", "add notes") - # Check the second call (git diff) - second_call_args, second_call_kwargs = mock_subprocess_run.call_args_list[1] - assert second_call_args[0] == ["git", "diff", "--name-status", "HEAD~1", "HEAD"] - assert second_call_kwargs["check"] is False + result = CliRunner().invoke(app, ["sync"]) + assert result.exit_code == 0, result.output + assert (library / "symbols" / "New.kicad_sym").exists() + assert "1 libraries synced" in result.output + assert "demo: symbols" in result.output + assert "kilm sync --auto-setup" in result.output -def test_sync_with_auto_setup(mock_config, mock_subprocess_run, mock_path_methods): - """Test sync with auto-setup option.""" - # Mock the setup command function (imported within sync function) - with patch("kicad_lib_manager.commands.setup.command.setup") as mock_setup: - mock_setup.return_value = None # setup function returns None on success - runner = CliRunner() - result = runner.invoke(main, ["sync", "--auto-setup"]) +def test_sync_counts_an_up_to_date_pull_in_any_language( + library: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Up to date is judged by HEAD, not by the wording of git's message.""" + monkeypatch.setenv("LC_ALL", "de_DE.UTF-8") + monkeypatch.delenv("LANGUAGE", raising=False) + if "Already up to date" in _git(library, "pull"): + pytest.skip("git has no German translation here") - assert result.exit_code == 0 - assert "Running 'kilm setup'" in result.output + result = CliRunner().invoke(app, ["sync"]) - # Verify that setup was called - mock_setup.assert_called_once() + assert result.exit_code == 0, result.output + assert "0 libraries synced" in result.output + assert "1 libraries up to date" in result.output -def test_sync_with_already_up_to_date(mock_config, mock_path_methods): - """Test update when repositories are already up to date.""" - # Create a mock that returns "Already up to date" for git pull - mock_run = MagicMock() - result = MagicMock() - result.returncode = 0 - result.stdout = "Already up to date." - mock_run.return_value = result +def test_sync_dry_run_does_not_pull(upstream: Path, library: Path) -> None: + """A dry run names the library and leaves it untouched.""" + _commit_file(upstream, "symbols/New.kicad_sym", "add symbols") - with patch("subprocess.run", mock_run): - runner = CliRunner() - result = runner.invoke(main, ["sync"]) + result = CliRunner().invoke(app, ["sync", "--dry-run"]) - assert result.exit_code == 0 - assert "Up to date" in result.output - assert "0 libraries synced" in result.output - assert "1 libraries up to date" in result.output + assert result.exit_code == 0, result.output + assert "Dry run: would execute 'git pull'" in result.output + assert not (library / "symbols").exists() -def test_sync_with_verbose(mock_config, mock_subprocess_run, mock_path_methods): - """Test update with verbose option.""" - runner = CliRunner() - result = runner.invoke(main, ["sync", "--verbose"]) +@pytest.mark.usefixtures("library") +def test_sync_auto_setup_runs_setup_with_its_defaults( + upstream: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Setup runs with its option defaults, so it looks up the library itself.""" + monkeypatch.setattr(setup_sources, "find_library_variable", lambda _name: None) + _commit_file(upstream, "symbols/New.kicad_sym", "add symbols") - assert result.exit_code == 0 - assert "Success:" in result.output + result = CliRunner().invoke(app, ["sync", "--auto-setup"]) + assert "Running 'kilm setup'" in result.output + assert "KICAD_USER_LIB not set and not provided" in result.output + assert "Error: 1" not in result.output + assert result.exit_code == 1 -def test_sync_dry_run(mock_config, mock_subprocess_run, mock_path_methods): - """Test update with dry-run option.""" - runner = CliRunner() - result = runner.invoke(main, ["sync", "--dry-run"]) - assert result.exit_code == 0 - assert "Dry run: would execute 'git pull'" in result.output +def test_sync_skips_a_directory_that_is_not_a_repository( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A configured path without ``.git`` is skipped, not pulled.""" + plain = tmp_path / "plain" + plain.mkdir() + config = FakeConfig([{"name": "plain", "path": str(plain), "type": "github"}]) + monkeypatch.setattr(sync_command, "Config", lambda: config) - # Verify subprocess was not called for git pull - mock_subprocess_run.assert_not_called() + result = CliRunner().invoke(app, ["sync"]) + assert result.exit_code == 0, result.output + assert "Not a git repository" in result.output + assert "1 libraries skipped" in result.output -def test_sync_no_libraries(monkeypatch): - """Test sync when no libraries are configured.""" - config_mock = MagicMock() - config_mock.get_libraries.return_value = [] - monkeypatch.setattr( - "kicad_lib_manager.commands.sync.command.Config", lambda: config_mock - ) - runner = CliRunner() - result = runner.invoke(main, ["sync"]) +def test_sync_without_libraries(monkeypatch: pytest.MonkeyPatch) -> None: + """No configured GitHub library is not an error.""" + monkeypatch.setattr(sync_command, "Config", lambda: FakeConfig([])) + + result = CliRunner().invoke(app, ["sync"]) assert result.exit_code == 0 assert "No GitHub libraries configured" in result.output -def test_check_for_library_changes(): - """Test the library change detection function.""" - # Create a temporary test directory - tmp_path = Path("/tmp/test_lib") - - # Mock subprocess.run to simulate git diff output - with patch("subprocess.run") as mock_run: - # Mock successful git diff with symbol library changes - result = MagicMock() - result.returncode = 0 - result.stdout = ( - "A\tsymbols/newlib.kicad_sym\nM\tfootprints/existing.pretty/file.kicad_mod" - ) - mock_run.return_value = result - - # Test the function with new signature (only lib_path) - changes = check_for_library_changes(tmp_path) - - # Should detect both symbols and footprints changes - assert "symbols" in changes - assert "footprints" in changes - assert "templates" not in changes - - # Verify git diff was called correctly - mock_run.assert_called_once_with( - ["git", "diff", "--name-status", "HEAD~1", "HEAD"], - cwd=tmp_path, - capture_output=True, - text=True, - check=False, - ) - - -def test_check_for_library_changes_fallback(): - """Test the library change detection function fallback when git diff fails.""" - # Create a temporary test directory - tmp_path = Path("/tmp/test_lib") - - # Mock subprocess.run to simulate git diff failure - with patch("subprocess.run") as mock_run: - # Mock failed git diff - result = MagicMock() - result.returncode = 1 - result.stdout = "" - mock_run.return_value = result - - # Mock file existence with a patch for fallback behavior - with ( - patch.object(Path, "exists", return_value=True), - patch.object(Path, "is_dir", return_value=True), - patch.object(Path, "glob") as mock_glob, - ): - # Setup mock glob to return symbol files - def mock_glob_func(pattern): - if "**/*.kicad_sym" in pattern: - mock_file = MagicMock() - mock_file.name = "test.kicad_sym" - return [mock_file] - return [] - - mock_glob.side_effect = mock_glob_func - - # Test the function fallback behavior - changes = check_for_library_changes(tmp_path) - assert "symbols" in changes - assert "footprints" not in changes - assert "templates" not in changes +def test_check_for_library_changes_without_history(tmp_path: Path) -> None: + """Without a previous commit the current content is reported.""" + repo = tmp_path / "fresh" + repo.mkdir() + _git(repo, "init", "--quiet") + _commit_file(repo, "footprints/Part.pretty/Part.kicad_mod", "footprint") + _commit_file(repo, "templates/basic/metadata.yaml", "template") + (repo / "symbols").mkdir() + + assert check_for_library_changes(repo) == ["templates"] + assert sorted(check_for_library_changes(repo, "HEAD~5")) == [ + "footprints", + "templates", + ] + + +def test_check_for_library_changes_detects_a_renamed_library(tmp_path: Path) -> None: + """A renamed library file is a new library name that setup must add.""" + repo = tmp_path / "renamed" + repo.mkdir() + _git(repo, "init", "--quiet") + _commit_file(repo, "symbols/Old.kicad_sym", "symbols") + _git(repo, "mv", "symbols/Old.kicad_sym", "symbols/New.kicad_sym") + _git(repo, "commit", "--quiet", "--message", "rename") + + assert check_for_library_changes(repo) == ["symbols"] diff --git a/tests/test_template.py b/tests/test_template.py index 9332bdb..7bbbf42 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -1,338 +1,357 @@ -""" -Tests for template utilities and commands. -""" +"""Tests for the template utilities.""" -import shutil -import tempfile +import json +from collections.abc import Sequence from pathlib import Path -from unittest.mock import patch import pytest -import yaml -from kicad_lib_manager.utils.template import ( +from kicad_lib_manager.utils.constants import ( HOOKS_DIR, - POST_CREATE_HOOK, TEMPLATE_CONTENT_DIR, TEMPLATE_METADATA, TEMPLATES_DIR, - create_project_from_template, +) +from kicad_lib_manager.utils.template.authoring import ( create_template_metadata, + create_template_structure, +) +from kicad_lib_manager.utils.template.constants import POST_CREATE_HOOK +from kicad_lib_manager.utils.template.discovery import ( + ConfiguredLibrary, find_all_templates, + list_templates_in_directory, +) +from kicad_lib_manager.utils.template.hooks import ( + create_hook_script, + run_post_create_hook, +) +from kicad_lib_manager.utils.template.models import ( + DiscoveredTemplate, + TemplateMetadata, + discovered_template, + read_template_metadata, + write_template_metadata, +) +from kicad_lib_manager.utils.template.project import create_project_from_template +from kicad_lib_manager.utils.template.rendering import ( render_filename, render_filename_custom, render_template_file, render_template_string, - write_template_metadata, ) +from kicad_lib_manager.utils.template.selection import find_potential_variables +PROJECT = {"project_name": "MyProject"} +SPACED_PROJECT = {"project_name": "My Project"} -class TestTemplateUtils: - """Tests for template utility functions.""" - - def setup_method(self): - """Set up test environment.""" - self.temp_dir = tempfile.mkdtemp() - self.templates_dir = Path(self.temp_dir) / TEMPLATES_DIR - self.templates_dir.mkdir(exist_ok=True) - - def teardown_method(self): - """Clean up test environment.""" - shutil.rmtree(self.temp_dir) - - def test_render_template_string(self): - """Test rendering a template string.""" - template_str = "Hello, {{ name }}!" - variables = {"name": "World"} - result = render_template_string(template_str, variables) - assert result == "Hello, World!" - - def test_render_filename(self): - """Test rendering a filename with variables.""" - filename = "{{ project_name }}.kicad_pro" - variables = {"project_name": "MyProject"} - result = render_filename(filename, variables) - assert result == "MyProject.kicad_pro" - - def test_render_filename_no_variables(self): - """Test rendering a filename with no variables.""" - filename = "project.kicad_pro" - variables = {"project_name": "MyProject"} - result = render_filename(filename, variables) - assert result == "project.kicad_pro" - - def test_render_filename_custom_basic(self): - """Test basic custom filename rendering.""" - filename = "%{project_name}.kicad_pro" - variables = {"project_name": "MyProject"} - result = render_filename_custom(filename, variables) - assert result == "MyProject.kicad_pro" - - def test_render_filename_custom_lower(self): - """Test custom filename rendering with lower transformation.""" - filename = "%{project_name.lower}.kicad_pro" - variables = {"project_name": "MyProject"} - result = render_filename_custom(filename, variables) - assert result == "myproject.kicad_pro" - - def test_render_filename_custom_upper(self): - """Test custom filename rendering with upper transformation.""" - filename = "%{project_name.upper}.kicad_pro" - variables = {"project_name": "MyProject"} - result = render_filename_custom(filename, variables) - assert result == "MYPROJECT.kicad_pro" - - def test_render_filename_custom_replace(self): - """Test custom filename rendering with replace transformation.""" - filename = "%{project_name.replace(' ', '-')}.kicad_pro" - variables = {"project_name": "My Project"} - result = render_filename_custom(filename, variables) - assert result == "My-Project.kicad_pro" - - def test_render_filename_custom_chained(self): - """Test custom filename rendering with chained transformations.""" - filename = "%{project_name.replace(' ', '-').lower}.kicad_pro" - variables = {"project_name": "My Project"} - result = render_filename_custom(filename, variables) - assert result == "my-project.kicad_pro" - - def test_render_filename_custom_replace_quotes(self): - """Test custom filename rendering with quoted replace arguments.""" - filename = "%{project_name.replace(' ', '_')}.kicad_pro" - variables = {"project_name": "My Project"} - result = render_filename_custom(filename, variables) - assert result == "My_Project.kicad_pro" - - def test_render_filename_custom_missing_variable(self): - """Test custom filename rendering with missing variable.""" - filename = "%{missing_var}.kicad_pro" - variables = {"project_name": "MyProject"} - result = render_filename_custom(filename, variables) - # Should return original when variable is missing - assert result == "%{missing_var}.kicad_pro" - - def test_render_filename_fallback_to_jinja(self): - """Test that render_filename falls back to Jinja2 for backward compatibility.""" - filename = "{{ project_name }}.kicad_pro" - variables = {"project_name": "MyProject"} - result = render_filename(filename, variables) - assert result == "MyProject.kicad_pro" - - def test_render_filename_custom_priority(self): - """Test that custom syntax takes priority over Jinja2.""" - filename = "%{project_name}.kicad_pro" - variables = {"project_name": "MyProject"} - result = render_filename(filename, variables) - assert result == "MyProject.kicad_pro" - - def test_create_project_template_structure(self): - """Test creating a template structure.""" - # Create a template directory - template_name = "test-template" - template_dir = self.templates_dir / template_name - template_dir.mkdir(exist_ok=True) - - # Create template metadata - metadata = create_template_metadata( - name=template_name, - description="Test template", - use_case="For testing", - variables={ - "project_name": { - "description": "Project name", - "default": "TestProject", - } - }, - ) - - # Write metadata - write_template_metadata(template_dir, metadata) - - # Check that metadata was written - metadata_file = template_dir / TEMPLATE_METADATA - assert metadata_file.exists() - - # Read back metadata - with Path(metadata_file).open() as f: - read_metadata = yaml.safe_load(f) - - # Check metadata contents - assert read_metadata is not None - assert read_metadata["name"] == template_name - assert read_metadata["description"] == "Test template" - assert read_metadata["use_case"] == "For testing" - assert "project_name" in read_metadata["variables"] - def test_render_template_file(self): - """Test rendering a template file.""" - # Create a template file - template_dir = Path(self.temp_dir) / "template_files" - template_dir.mkdir(exist_ok=True) +def test_render_template_string() -> None: + """Jinja2 placeholders are filled from the variables.""" + assert render_template_string("Hello, {{ name }}!", {"name": "World"}) == ( + "Hello, World!" + ) - template_file = template_dir / "test.txt.jinja2" - with Path(template_file).open("w") as f: - f.write("Hello, {{ name }}!") - # Render the template file - target_dir = Path(self.temp_dir) / "target" - target_dir.mkdir(exist_ok=True) - - target_file = target_dir / "test.txt" - - variables = {"name": "World"} - - success = render_template_file( - source_file=template_file, target_file=target_file, variables=variables - ) - - assert success - assert target_file.exists() - - # Check contents - with Path(target_file).open() as f: - content = f.read() - - assert content == "Hello, World!" - - -class TestTemplateCreation: - """Tests for template creation functionality.""" - - def setup_method(self): - """Set up test environment.""" - self.temp_dir = tempfile.mkdtemp() - self.template_dir = Path(self.temp_dir) / "template" - self.template_content_dir = self.template_dir / TEMPLATE_CONTENT_DIR - self.hooks_dir = self.template_dir / HOOKS_DIR - - # Create template structure - self.template_dir.mkdir(exist_ok=True) - self.template_content_dir.mkdir(exist_ok=True) - self.hooks_dir.mkdir(exist_ok=True) - - # Create metadata - metadata = { - "name": "test-template", - "description": "Test template", - "use_case": "For testing", - "variables": { - "project_name": { - "description": "Project name", - "default": "TestProject", - }, - "directory_name": { - "description": "Directory name", - "default": "{{ project_name.lower.replace(' ', '-') }}", - }, - }, - } - - # Write metadata - with Path(self.template_dir / TEMPLATE_METADATA).open("w") as f: - yaml.dump(metadata, f) - - # Create post-creation hook - with Path(self.hooks_dir / POST_CREATE_HOOK).open("w") as f: - f.write(""" -def post_create(context): - print(f"Project created successfully: {context['variables']['project_name']}") -""") - - # Create template files - test_file = self.template_content_dir / "README.md.jinja2" - with Path(test_file).open("w") as f: - f.write("# {{ project_name }}\n\nThis is a test project.") - - template_file = self.template_content_dir / "{{ project_name }}.txt.jinja2" - with Path(template_file).open("w") as f: - f.write("Hello from {{ project_name }}!") - - def teardown_method(self): - """Clean up test environment.""" - shutil.rmtree(self.temp_dir) - - def test_create_project_from_template(self): - """Test creating a project from a template.""" - project_dir = Path(self.temp_dir) / "project" - - variables = {"project_name": "MyProject", "directory_name": "my-project"} - - # Mock the post-creation hook to avoid runtime errors - with patch("kicad_lib_manager.utils.template.run_post_create_hook"): - success = create_project_from_template( - template_dir=self.template_dir, - project_dir=project_dir, - variables=variables, - dry_run=False, - skip_hooks=False, - ) - - assert success - - # Check that files were created - assert (project_dir / "README.md").exists() - assert (project_dir / "MyProject.txt").exists() - - # Check contents - with Path(project_dir / "README.md").open() as f: - content = f.read() - - assert content == "# MyProject\n\nThis is a test project." - - with Path(project_dir / "MyProject.txt").open() as f: - content = f.read() - - assert content == "Hello from MyProject!" - - def test_create_project_dry_run(self): - """Test dry run of project creation.""" - project_dir = Path(self.temp_dir) / "project_dry_run" - - variables = {"project_name": "MyProject", "directory_name": "my-project"} - - # Create project with dry run - success = create_project_from_template( - template_dir=self.template_dir, - project_dir=project_dir, - variables=variables, - dry_run=True, - skip_hooks=True, - ) - - assert success - - # Check that files were NOT created - assert not project_dir.exists() +@pytest.mark.parametrize( + ("filename", "expected"), + [ + ("{{ project_name }}.kicad_pro", "MyProject.kicad_pro"), + ("%{project_name}.kicad_pro", "MyProject.kicad_pro"), + ("project.kicad_pro", "project.kicad_pro"), + ], +) +def test_render_filename(filename: str, expected: str) -> None: + """Both placeholder syntaxes render; plain names stay unchanged.""" + assert render_filename(filename, PROJECT) == expected + + +@pytest.mark.parametrize( + ("filename", "variables", "expected"), + [ + ("%{project_name}.kicad_pro", PROJECT, "MyProject.kicad_pro"), + ("%{project_name.lower}.kicad_pro", PROJECT, "myproject.kicad_pro"), + ("%{project_name.upper}.kicad_pro", PROJECT, "MYPROJECT.kicad_pro"), + ( + "%{project_name.replace(' ', '-')}.kicad_pro", + SPACED_PROJECT, + "My-Project.kicad_pro", + ), + ( + "%{project_name.replace(' ', '_')}.kicad_pro", + SPACED_PROJECT, + "My_Project.kicad_pro", + ), + ( + "%{project_name.replace(' ', '-').lower}.kicad_pro", + SPACED_PROJECT, + "my-project.kicad_pro", + ), + ("%{missing_var}.kicad_pro", PROJECT, "%{missing_var}.kicad_pro"), + ], +) +def test_render_filename_custom( + filename: str, variables: dict[str, str], expected: str +) -> None: + """Transformations apply in order; unknown variables stay as written.""" + assert render_filename_custom(filename, variables) == expected + + +def test_template_metadata_round_trip(tmp_path: Path) -> None: + """Written metadata reads back with the predefined variables added.""" + metadata = create_template_metadata( + name="test-template", + description="Test template", + use_case="For testing", + variables={"author": {"description": "Author", "default": "Jane"}}, + ) + write_template_metadata(tmp_path, metadata) + + read_back = read_template_metadata(tmp_path) + + assert read_back == metadata + assert list(metadata.get("variables", {})) == [ + "author", + "project_name", + "directory_name", + "project_filename", + ] + + +def test_render_template_file(tmp_path: Path) -> None: + """A template file renders into its target.""" + source = tmp_path / "test.txt.jinja2" + source.write_text("Hello, {{ name }}!", encoding="utf-8") + target = tmp_path / "target" / "test.txt" + + assert render_template_file(source, target, {"name": "World"}) + assert target.read_text(encoding="utf-8") == "Hello, World!" + + +def test_rendered_files_keep_markup_characters(tmp_path: Path) -> None: + """Variables are inserted verbatim, not HTML-escaped.""" + source = tmp_path / "title.txt.jinja2" + source.write_text("{{ project_name }}", encoding="utf-8") + target = tmp_path / "title.txt" + + render_template_file(source, target, {"project_name": "R&D <1>"}) + + assert target.read_text(encoding="utf-8") == "R&D <1>" + + +def _discovered(template_dir: Path, metadata: TemplateMetadata) -> DiscoveredTemplate: + return discovered_template(metadata, template_dir, template_dir.parent) + + +@pytest.fixture +def template(tmp_path: Path) -> DiscoveredTemplate: + """A template with a rendered README, a renamed file, and a recording hook.""" + template_dir = tmp_path / "template" + content_dir = template_dir / TEMPLATE_CONTENT_DIR + content_dir.mkdir(parents=True) + (content_dir / "README.md.jinja2").write_text( + "# {{ project_name }}\n\nThis is a test project.", encoding="utf-8" + ) + (content_dir / "{{ project_name }}.txt.jinja2").write_text( + "Hello from {{ project_name }}!", encoding="utf-8" + ) + (template_dir / HOOKS_DIR).mkdir() + (template_dir / HOOKS_DIR / POST_CREATE_HOOK).write_text( + "from pathlib import Path\n\n\ndef post_create(context):\n" + " name = context['variables']['project_name']\n" + " Path(context['project_dir'], 'hook.txt').write_text(name)\n", + encoding="utf-8", + ) + return _discovered(template_dir, create_template_metadata("test-template")) + + +def test_create_project_from_template( + template: DiscoveredTemplate, tmp_path: Path +) -> None: + """Files render with the variables and the hook runs in the new project.""" + project_dir = tmp_path / "project" + + assert create_project_from_template(template, project_dir, PROJECT) + + assert sorted(path.name for path in project_dir.iterdir()) == [ + "MyProject.txt", + "README.md", + "hook.txt", + ] + readme = (project_dir / "README.md").read_text(encoding="utf-8") + assert readme == "# MyProject\n\nThis is a test project." + text = (project_dir / "MyProject.txt").read_text(encoding="utf-8") + assert text == "Hello from MyProject!" + assert (project_dir / "hook.txt").read_text(encoding="utf-8") == "MyProject" + + +def test_hook_reads_custom_metadata(tmp_path: Path) -> None: + """Keys KiLM does not know reach the hook as metadata.yaml has them.""" + template_dir = tmp_path / "template" + (template_dir / TEMPLATE_CONTENT_DIR).mkdir(parents=True) + (template_dir / TEMPLATE_CONTENT_DIR / "README.md").write_text("", encoding="utf-8") + (template_dir / HOOKS_DIR).mkdir() + (template_dir / TEMPLATE_METADATA).write_text( + "name: tpl\n" + "hook_options:\n greeting: Hi\n" + "variables:\n board:\n default: A\n choices: [A, B]\n", + encoding="utf-8", + ) + (template_dir / HOOKS_DIR / POST_CREATE_HOOK).write_text( + "from pathlib import Path\n\n\ndef post_create(context):\n" + " template = context['template']\n" + " choices = template['variables']['board']['choices']\n" + " text = template['hook_options']['greeting'] + ' ' + ','.join(choices)\n" + " Path(context['project_dir'], 'hook.txt').write_text(text)\n", + encoding="utf-8", + ) + metadata = read_template_metadata(template_dir) + assert metadata is not None + project_dir = tmp_path / "project" + + assert create_project_from_template( + _discovered(template_dir, metadata), project_dir, PROJECT + ) + + assert (project_dir / "hook.txt").read_text(encoding="utf-8") == "Hi A,B" + + +def test_hook_sees_custom_keys_as_loaded(tmp_path: Path) -> None: + """Integer and text keys stay distinct, also inside variables and dependencies.""" + template_dir = tmp_path / TEMPLATES_DIR / "tpl" + (template_dir / TEMPLATE_CONTENT_DIR).mkdir(parents=True) + (template_dir / TEMPLATE_CONTENT_DIR / "README.md").write_text("", encoding="utf-8") + (template_dir / HOOKS_DIR).mkdir() + (template_dir / TEMPLATE_METADATA).write_text( + "name: tpl\n7: first\n'7': second\n" + "variables:\n board:\n default: A\n 7: int\n '7': text\n" + "dependencies:\n recommended: []\n 7: dep\n", + encoding="utf-8", + ) + (template_dir / HOOKS_DIR / POST_CREATE_HOOK).write_text( + "from pathlib import Path\n\n\ndef post_create(context):\n" + " t = context['template']\n" + " board = t['variables']['board']\n" + " values = [t[7], t['7'], board[7], board['7'], t['dependencies'][7]]\n" + " Path(context['project_dir'], 'hook.txt').write_text(' '.join(values))\n", + encoding="utf-8", + ) + templates = list_templates_in_directory(tmp_path) + assert [template["name"] for template in templates] == ["tpl"] + project_dir = tmp_path / "project" + + assert create_project_from_template(templates[0], project_dir, PROJECT) + + hook_output = (project_dir / "hook.txt").read_text(encoding="utf-8") + assert hook_output == "first second int text dep" + + +def test_create_project_dry_run_writes_nothing( + template: DiscoveredTemplate, tmp_path: Path +) -> None: + """A dry run succeeds without creating the project directory.""" + project_dir = tmp_path / "project" + + assert create_project_from_template(template, project_dir, PROJECT, dry_run=True) + assert not project_dir.exists() + + +def test_starting_hook_runs(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """The hook that template make writes loads and runs as written.""" + create_hook_script(tmp_path) + + run_post_create_hook( + tmp_path / POST_CREATE_HOOK, + tmp_path / "project", + PROJECT, + create_template_metadata("tpl"), + ) + + assert "Project MyProject created successfully!" in capsys.readouterr().out class _LibraryConfig: """Config stand-in exposing only the libraries find_all_templates reads.""" - def __init__(self, libraries: list[dict[str, str]]) -> None: + def __init__(self, libraries: list[ConfiguredLibrary]) -> None: self._libraries = libraries - def get_libraries(self) -> list[dict[str, str]]: + def get_libraries(self) -> Sequence[ConfiguredLibrary]: return self._libraries def test_find_all_templates_keeps_first_duplicate(tmp_path: Path) -> None: """The first configured library wins a template name clash.""" - libraries = [] + libraries: list[ConfiguredLibrary] = [] for library_name in ("first", "second"): template_dir = tmp_path / library_name / TEMPLATES_DIR / "board" template_dir.mkdir(parents=True) write_template_metadata( template_dir, create_template_metadata("board", description=library_name) ) - libraries.append({"name": library_name, "path": str(tmp_path / library_name)}) + libraries.append( + ConfiguredLibrary(name=library_name, path=str(tmp_path / library_name)) + ) templates = find_all_templates(_LibraryConfig(libraries)) board = templates["board"] - assert board["description"] == "first" + assert board.get("description") == "first" assert board["source_library"] == "first" assert board["library_path"] == str(tmp_path / "first") -if __name__ == "__main__": - pytest.main() +def test_main_kicad_files_are_named_after_the_new_project(tmp_path: Path) -> None: + """The main project, schematic, and PCB take the project_filename variable.""" + source = tmp_path / "source" + source.mkdir() + (source / "Board.kicad_pro").write_text( + '{"meta": {"filename": "Board.kicad_pro"}}', encoding="utf-8" + ) + (source / "Board.kicad_sch").write_text( + '(kicad_sch (project "Board"))', encoding="utf-8" + ) + (source / "Board.kicad_pcb").write_text( + '(kicad_pcb (sheetfile "Board.kicad_sch"))', encoding="utf-8" + ) + template_dir = tmp_path / "template" + metadata = create_template_metadata("tpl") + create_template_structure(source, template_dir, metadata) + project = tmp_path / "project" + + create_project_from_template( + _discovered(template_dir, metadata), + project, + {"project_name": "Widget", "project_filename": "Widget"}, + skip_hooks=True, + ) + + assert sorted(path.name for path in project.iterdir()) == [ + "Widget.kicad_pcb", + "Widget.kicad_pro", + "Widget.kicad_sch", + ] + project_file = json.loads((project / "Widget.kicad_pro").read_text()) + assert project_file["meta"]["filename"] == "Widget.kicad_pro" + schematic = (project / "Widget.kicad_sch").read_text(encoding="utf-8") + assert '(project "Widget")' in schematic + pcb = (project / "Widget.kicad_pcb").read_text(encoding="utf-8") + assert '(sheetfile "Widget.kicad_sch")' in pcb + + +def test_variable_scan_skips_directories_make_does_not_copy(tmp_path: Path) -> None: + """Dependencies and VCS data do not contribute variable suggestions.""" + (tmp_path / "node_modules" / "pkg").mkdir(parents=True) + (tmp_path / "node_modules" / "pkg" / "index.js").write_text( + 'author = "Someone"', encoding="utf-8" + ) + (tmp_path / "notes.txt").write_text('author = "Jane"', encoding="utf-8") + + assert find_potential_variables(tmp_path) == {"jane": ["Jane"]} + + +def test_variable_scan_with_no_patterns_finds_nothing(tmp_path: Path) -> None: + """An empty pattern list means no patterns, not the default ones.""" + (tmp_path / "notes.txt").write_text('author = "Jane"', encoding="utf-8") + + assert find_potential_variables(tmp_path, patterns=[]) == {} diff --git a/tests/test_template_commands.py b/tests/test_template_commands.py new file mode 100644 index 0000000..d8dded1 --- /dev/null +++ b/tests/test_template_commands.py @@ -0,0 +1,228 @@ +"""Tests for the ``kilm template`` commands, run through the Typer app.""" + +import json +from pathlib import Path + +import pytest +import questionary +import yaml +from typer.testing import CliRunner + +from kicad_lib_manager.commands.template import template_app +from kicad_lib_manager.services.config_service import Config +from kicad_lib_manager.utils.constants import ( + TEMPLATE_CONTENT_DIR, + TEMPLATE_METADATA, + TEMPLATES_DIR, +) + + +def add_library(home: Path, name: str) -> Path: + """Configure a GitHub library called ``name`` and return its directory.""" + library = home / name + (library / TEMPLATES_DIR).mkdir(parents=True) + Config().add_library(name, str(library), "github") + return library + + +def add_template( + library: Path, + name: str, + files: dict[str, str], + variables: dict[str, dict[str, str]] | None = None, + description: str = "", +) -> Path: + """Write a template with the given content files into ``library``.""" + template_dir = library / TEMPLATES_DIR / name + content_dir = template_dir / TEMPLATE_CONTENT_DIR + content_dir.mkdir(parents=True) + metadata = { + "name": name, + "description": description, + "variables": variables or {}, + } + (template_dir / TEMPLATE_METADATA).write_text(yaml.safe_dump(metadata)) + for relative, content in files.items(): + (content_dir / relative).write_text(content) + return template_dir + + +def invoke(*args: str, user_input: str | None = None) -> str: + """Run ``kilm template`` with ``args``, require success, and return the output.""" + result = CliRunner().invoke(template_app, list(args), input=user_input) + assert result.exit_code == 0, result.output + return result.output + + +def test_list_json_is_valid_json_for_long_values(home: Path) -> None: + """Long values are not wrapped into the JSON output.""" + library = add_library(home, "lib") + add_template(library, "board", {}, description="x" * 150) + + templates = json.loads(invoke("list", "--json")) + + assert templates["board"]["description"] == "x" * 150 + + +def test_list_json_keeps_custom_metadata(home: Path) -> None: + """Keys KiLM does not know, YAML dates included, appear in the JSON.""" + template_dir = add_template(add_library(home, "lib"), "board", {}) + with (template_dir / TEMPLATE_METADATA).open("a", encoding="utf-8") as f: + f.write("released: 2026-01-02\nhook_options:\n greeting: Hi\n") + + templates = json.loads(invoke("list", "--json")) + + assert templates["board"]["hook_options"] == {"greeting": "Hi"} + assert templates["board"]["released"] == "2026-01-02" + + +def test_list_json_prints_keys_json_cannot_hold_as_text(home: Path) -> None: + """Custom keys such as YAML dates print as text instead of failing.""" + template_dir = add_template(add_library(home, "lib"), "board", {}) + with (template_dir / TEMPLATE_METADATA).open("a", encoding="utf-8") as f: + f.write("2026-01-02: released\nhook_options:\n 2026-01-03: tagged\n") + + templates = json.loads(invoke("list", "--json")) + + assert templates["board"]["2026-01-02"] == "released" + assert templates["board"]["hook_options"] == {"2026-01-03": "tagged"} + + +def test_create_uses_template_from_requested_library( + home: Path, tmp_path: Path +) -> None: + """--library selects that library's template when names clash.""" + for library_name in ("first", "second"): + add_template( + add_library(home, library_name), + "board", + {"origin.txt.jinja2": library_name}, + variables={"directory_name": {"description": "", "default": ""}}, + ) + + invoke( + "create", + "Widget", + str(tmp_path), + "--template", + "board", + "--library", + "second", + "--set-var", + "directory_name=widget", + ) + + assert (tmp_path / "widget" / "origin.txt").read_text() == "second" + + +def test_list_filters_library_before_resolving_name_clashes(home: Path) -> None: + """--library lists its template even when an earlier library shares the name.""" + for library_name in ("first", "second"): + add_template( + add_library(home, library_name), "board", {}, description=library_name + ) + + templates = json.loads(invoke("list", "--library", "second", "--json")) + + assert templates["board"]["description"] == "second" + + +def test_create_renders_percent_syntax_defaults( + home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Defaults written in the %{...} syntax are rendered before they are offered.""" + offered: dict[str, str] = {} + + class AcceptDefault: + def __init__(self, message: str, default: str) -> None: + self.answer = default + offered[message.split(maxsplit=1)[0]] = default + + def ask(self) -> str: + return self.answer + + monkeypatch.setattr(questionary, "text", AcceptDefault) + add_template( + add_library(home, "lib"), + "board", + {"%{project_filename}.txt.jinja2": "{{ directory_name }}"}, + variables={ + "project_name": {"description": "Name", "default": ""}, + "directory_name": { + "description": "Directory", + "default": "%{project_name.lower.replace(' ', '-')}", + }, + "project_filename": {"description": "File", "default": "%{project_name}"}, + }, + ) + + invoke("create", "My Board", str(tmp_path), "--template", "board") + + assert offered == {"directory_name": "my-board", "project_filename": "My Board"} + assert (tmp_path / "my-board" / "My Board.txt").read_text() == "my-board" + + +def test_make_keeps_description_when_detected_variable_is_described( + home: Path, tmp_path: Path +) -> None: + """Describing a detected variable does not replace the template description.""" + add_library(home, "lib") + source = tmp_path / "source" + source.mkdir() + (source / "notes.txt").write_text('author = "Jane"') + output = tmp_path / "out" + + invoke( + "make", + "board", + str(source), + "--description", + "Board template", + "--use-case", + "Boards", + "--output-directory", + str(output), + user_input="y\nAuthor name\nn\n", + ) + + metadata = yaml.safe_load((output / TEMPLATE_METADATA).read_text()) + assert metadata["description"] == "Board template" + assert metadata["variables"]["jane"]["description"] == "Author name" + + +def test_make_dry_run_lists_only_files_that_make_copies( + home: Path, tmp_path: Path +) -> None: + """The preview excludes what make itself excludes.""" + add_library(home, "lib") + source = tmp_path / "source" + (source / ".git").mkdir(parents=True) + (source / ".git" / "HEAD").write_text("ref") + (source / "board.kicad_prl").write_text("{}") + (source / "notes.txt").write_text("notes") + + output = invoke( + "make", + "board", + str(source), + "--non-interactive", + "--dry-run", + "--output-directory", + str(tmp_path / "out"), + ) + + included = [line.strip() for line in output.splitlines() if "+ " in line] + assert included == ["+ notes.txt"] + + +@pytest.mark.parametrize("library_args", [[], ["--library", "lib"]]) +def test_list_without_templates_suggests_existing_commands( + home: Path, library_args: list[str] +) -> None: + """The hints for an empty listing name commands that exist.""" + add_library(home, "lib") + + output = invoke("list", *library_args) + + assert "kilm list-libraries" not in output + assert "kilm config list" in output diff --git a/tests/test_unpin_command.py b/tests/test_unpin_command.py index 228d527..32ee895 100644 --- a/tests/test_unpin_command.py +++ b/tests/test_unpin_command.py @@ -1,100 +1,116 @@ -""" -Tests for the unpin command functionality. -""" +"""Tests for ``kilm unpin`` against a temporary KiCad configuration.""" +import json from pathlib import Path -from unittest.mock import patch +import pytest from typer.testing import CliRunner -from kicad_lib_manager.main import app as main - - -class TestUnpinCommand: - """Test cases for the unpin command.""" - - def setup_method(self): - """Set up test fixtures.""" - self.runner = CliRunner() - - def test_mutual_exclusivity_all_with_symbols(self): - """Test that --all cannot be used with --symbols.""" - result = self.runner.invoke(main, ["unpin", "--all", "--symbols", "lib1"]) - assert result.exit_code == 1 # Typer validation error exit code - 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(main, ["unpin", "--all", "--footprints", "lib1"]) - assert result.exit_code == 1 # Typer validation error exit code - 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( - main, ["unpin", "--all", "--symbols", "lib1", "--footprints", "lib2"] - ) - assert result.exit_code == 1 # Typer validation error exit code - 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.services.library_service.LibraryService.find_kicad_config" - ) as mock_find_config: - mock_find_config.return_value = Path("/tmp/kicad") - - 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": []}}' - ) - - # Should not raise an error - result = self.runner.invoke(main, ["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.services.library_service.LibraryService.find_kicad_config" - ) as mock_find_config: - mock_find_config.return_value = Path("/tmp/kicad") - - 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": ["lib1"], "pinned_fp_libs": []}}' - - # Should not raise an error - result = self.runner.invoke(main, ["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.services.library_service.LibraryService.find_kicad_config" - ) as mock_find_config: - mock_find_config.return_value = Path("/tmp/kicad") - - 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": ["lib1"]}}' - - # Should not raise an error - result = self.runner.invoke(main, ["unpin", "--footprints", "lib1"]) - # Exit code 0 means success, or 1 if no libraries found (which is expected) - assert result.exit_code in [0, 1] +from kicad_lib_manager.main import app +from kicad_lib_manager.services.library_service import LibraryService +from kicad_lib_manager.utils.kicad_common import ( + JsonObject, + load_json_object, + session_of, +) +from tests.fault_injection import fail_writes + +CONFLICT = "'--all' cannot be used with '--symbols' or '--footprints'" + + +@pytest.fixture +def kicad_common(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Return a kicad_common.json with two pinned symbol and footprint libraries.""" + monkeypatch.setattr( + LibraryService, "find_kicad_config", staticmethod(lambda: tmp_path) + ) + path = tmp_path / "kicad_common.json" + session = { + "pinned_symbol_libs": ["sym_a", "sym_b"], + "pinned_fp_libs": ["fp_a", "fp_b"], + "remember_open_files": False, + } + path.write_text(json.dumps({"session": session}, indent=2), encoding="utf-8") + return path + + +def _session(path: Path) -> JsonObject: + """Return the ``session`` section of the kicad_common.json at ``path``.""" + session = session_of(load_json_object(path)) + assert session is not None + return session + + +@pytest.mark.parametrize( + "selection", + [ + ["--symbols", "lib1"], + ["--footprints", "lib1"], + ["--symbols", "lib1", "--footprints", "lib2"], + ], +) +def test_all_cannot_be_combined_with_a_selection(selection: list[str]) -> None: + """``--all`` with explicit libraries fails before touching KiCad.""" + result = CliRunner().invoke(app, ["unpin", "--all", *selection]) + + assert result.exit_code == 1 + assert CONFLICT in result.output + + +def test_unpin_without_selection_fails(kicad_common: Path) -> None: + """Nothing to unpin is an error that names the options.""" + result = CliRunner().invoke(app, ["unpin"]) + + assert result.exit_code == 1 + assert "No libraries specified to unpin" in result.output + assert _session(kicad_common)["pinned_symbol_libs"] == ["sym_a", "sym_b"] + + +def test_unpin_selected_libraries(kicad_common: Path) -> None: + """Only the named libraries are unpinned, and a backup is kept.""" + result = CliRunner().invoke( + app, ["unpin", "--symbols", "sym_a", "--footprints", "fp_b"] + ) + + assert result.exit_code == 0, result.output + session = _session(kicad_common) + assert session["pinned_symbol_libs"] == ["sym_b"] + assert session["pinned_fp_libs"] == ["fp_a"] + assert session["remember_open_files"] is False + assert len(list(kicad_common.parent.glob("kicad_common.json.backup.*"))) == 1 + + +def test_unpin_all_libraries(kicad_common: Path) -> None: + """``--all`` empties both pinned lists.""" + result = CliRunner().invoke(app, ["unpin", "--all"]) + + assert result.exit_code == 0, result.output + assert "Unpinned 2 symbol and 2 footprint libraries" in result.output + session = _session(kicad_common) + assert session["pinned_symbol_libs"] == [] + assert session["pinned_fp_libs"] == [] + + +def test_unpin_dry_run_changes_nothing(kicad_common: Path) -> None: + """A dry run reports the change and leaves the file alone.""" + before = kicad_common.read_text(encoding="utf-8") + + result = CliRunner().invoke(app, ["unpin", "--symbols", "sym_a", "--dry-run"]) + + assert result.exit_code == 0, result.output + assert "Would unpin 1 symbol and 0 footprint libraries" in result.output + assert kicad_common.read_text(encoding="utf-8") == before + + +def test_unpin_keeps_the_file_when_writing_fails( + kicad_common: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed write leaves kicad_common.json as it was.""" + before = kicad_common.read_text(encoding="utf-8") + fail_writes(monkeypatch) + + result = CliRunner().invoke(app, ["unpin", "--symbols", "sym_a"]) + + assert result.exit_code == 1 + assert "No space left on device" in result.output + assert kicad_common.read_text(encoding="utf-8") == before diff --git a/tests/test_update_commands.py b/tests/test_update_commands.py index 04d4b90..bc42cf4 100644 --- a/tests/test_update_commands.py +++ b/tests/test_update_commands.py @@ -1,512 +1,227 @@ -""" -Tests for update command group and subcommands. -""" +"""Tests for the `kilm update` command group.""" -from unittest.mock import MagicMock, patch +import importlib.metadata +from dataclasses import dataclass, field import pytest -import typer -from typer.testing import CliRunner +from typer.testing import CliRunner, Result -from kicad_lib_manager.commands.update.check import check_update_command +from kicad_lib_manager.commands.update import check, info, perform from kicad_lib_manager.commands.update.command import update_app -from kicad_lib_manager.commands.update.info import info_command -from kicad_lib_manager.commands.update.perform import perform_update_command +from kicad_lib_manager.services import update_service +from kicad_lib_manager.services.install_method import InstallMethod +from kicad_lib_manager.services.update_service import UpdateService -# Create test applications for individual commands -check_app = typer.Typer() -check_app.command()(check_update_command) -info_app = typer.Typer() -info_app.command()(info_command) +@dataclass +class FakeInstallation: + """An installed kilm 1.0.0 whose installer and PyPI are simulated.""" -perform_app = typer.Typer() -perform_app.command()(perform_update_command) + method: InstallMethod = "pipx" + latest: str | None = "1.1.0" + upgrade_error: str | None = None + use_cache_calls: list[bool] = field(default_factory=list) + upgrades: list[list[str]] = field(default_factory=list) + def service(self, current_version: str) -> UpdateService: + """Build the service the commands use, seeing this installation.""" + return UpdateService(current_version, method=self.method, version_checker=self) -@pytest.fixture -def runner(): - """Create a CLI runner for testing.""" - return CliRunner() + def check_latest_version(self, *, use_cache: bool = True) -> str | None: + """Answer version checks with `latest`, recording the cache choice.""" + self.use_cache_calls.append(use_cache) + return self.latest + + def run_upgrade(self, command: list[str]) -> str | None: + """Record an installer run; it fails with `upgrade_error` if set.""" + self.upgrades.append(command) + return self.upgrade_error @pytest.fixture -def mock_update_service(): - """Mock UpdateService for testing.""" - with ( - patch( - "kicad_lib_manager.commands.update.check.UpdateService" - ) as mock_service_check, - patch( - "kicad_lib_manager.commands.update.info.UpdateService" - ) as mock_service_info, - patch( - "kicad_lib_manager.commands.update.perform.UpdateService" - ) as mock_service_perform, - ): - service_instance = MagicMock() - mock_service_check.return_value = service_instance - mock_service_info.return_value = service_instance - mock_service_perform.return_value = service_instance - yield service_instance +def installation(monkeypatch: pytest.MonkeyPatch) -> FakeInstallation: + """Route the update commands to a simulated installation.""" + fake = FakeInstallation() + monkeypatch.setattr(importlib.metadata, "version", lambda _name: "1.0.0") + for module in (check, info, perform): + monkeypatch.setattr(module, "UpdateService", fake.service) + monkeypatch.setattr(update_service, "run_upgrade", fake.run_upgrade) + return fake -@pytest.fixture -def mock_version(): - """Mock importlib.metadata.version.""" - with ( - patch( - "kicad_lib_manager.commands.update.check.importlib.metadata.version" - ) as mock_ver_check, - patch( - "kicad_lib_manager.commands.update.info.importlib.metadata.version" - ) as mock_ver_info, - patch( - "kicad_lib_manager.commands.update.perform.importlib.metadata.version" - ) as mock_ver_perform, - ): - mock_ver_check.return_value = "1.0.0" - mock_ver_info.return_value = "1.0.0" - mock_ver_perform.return_value = "1.0.0" - yield mock_ver_check - - -class TestUpdateCheckCommand: - """Test the 'kilm update check' command.""" - - def test_check_update_available(self, runner, mock_update_service, mock_version): - """Test check command when update is available.""" - mock_update_service.check_for_updates.return_value = { - "has_update": True, - "current_version": "1.0.0", - "latest_version": "1.1.0", - "method": "pip", - "supports_auto_update": True, - } - mock_update_service.get_update_instructions.return_value = ( - "pip install --upgrade kilm" - ) - - result = runner.invoke(check_app) - - assert result.exit_code == 1 # Exit code 1 indicates update available +def kilm_update(*args: str) -> Result: + """Run `kilm update ARGS`.""" + return CliRunner().invoke(update_app, list(args)) + + +class TestCheck: + """`kilm update check`.""" + + def test_update_available(self, installation: FakeInstallation) -> None: + """A newer release is reported with exit status 1.""" + result = kilm_update("check") + assert result.exit_code == 1 assert "Update available!" in result.stdout assert "Current version: 1.0.0" in result.stdout assert "Latest version: 1.1.0" in result.stdout + assert "Installation method: pipx" in result.stdout + assert "kilm update perform" in result.stdout + assert installation.use_cache_calls == [True] + + def test_up_to_date(self, installation: FakeInstallation) -> None: + """Up to date exits 0, silently with --quiet.""" + installation.latest = "1.0.0" + result = kilm_update("check") + assert result.exit_code == 0 + assert "You are using the latest version (v1.0.0)" in result.stdout - def test_check_no_update_available(self, runner, mock_update_service, mock_version): - """Test check command when no update is available.""" - mock_update_service.check_for_updates.return_value = { - "has_update": False, - "current_version": "1.0.0", - "latest_version": "1.0.0", - "method": "pip", - "supports_auto_update": True, - } - - result = runner.invoke(check_app) - - assert result.exit_code == 0 # Exit code 0 indicates no update needed - assert "You are using the latest version" in result.stdout - - def test_check_quiet_mode_with_update( - self, runner, mock_update_service, mock_version - ): - """Test check command in quiet mode when update is available.""" - mock_update_service.check_for_updates.return_value = { - "has_update": True, - "current_version": "1.0.0", - "latest_version": "1.1.0", - "method": "pip", - "supports_auto_update": True, - } - - result = runner.invoke(check_app, ["--quiet"]) + quiet = kilm_update("check", "--quiet") + assert quiet.exit_code == 0 + assert not quiet.stdout.strip() + def test_quiet_still_reports_an_update( + self, installation: FakeInstallation + ) -> None: + """--quiet only silences the up-to-date message.""" + result = kilm_update("check", "-q") assert result.exit_code == 1 assert "Update available!" in result.stdout + assert installation.upgrades == [] - def test_check_quiet_mode_no_update( - self, runner, mock_update_service, mock_version - ): - """Test check command in quiet mode when no update is available.""" - mock_update_service.check_for_updates.return_value = { - "has_update": False, - "current_version": "1.0.0", - "latest_version": "1.0.0", - "method": "pip", - "supports_auto_update": True, - } - - result = runner.invoke(check_app, ["--quiet"]) - - assert result.exit_code == 0 - # Should have minimal output in quiet mode - assert result.stdout.strip() == "" + def test_force_bypasses_the_cache(self, installation: FakeInstallation) -> None: + """--force asks PyPI even when a recent answer is cached.""" + kilm_update("check", "--force") + assert installation.use_cache_calls == [False] - def test_check_force_flag(self, runner, mock_update_service, mock_version): - """Test check command with force flag to bypass cache.""" - mock_update_service.check_for_updates.return_value = { - "has_update": False, - "current_version": "1.0.0", - "latest_version": "1.0.0", - "method": "pip", - "supports_auto_update": True, - } + def test_manual_update_required(self, installation: FakeInstallation) -> None: + """Installs KiLM cannot update get the manual instruction.""" + installation.method = "nix" + result = kilm_update("check") + assert result.exit_code == 1 + assert "Manual update required: nix flake update" in result.stdout - result = runner.invoke(check_app, ["--force"]) - assert result.exit_code == 0 - # Verify cache was bypassed (use_cache=False) - mock_update_service.check_for_updates.assert_called_with(use_cache=False) - - def test_check_manual_update_required( - self, runner, mock_update_service, mock_version - ): - """Test check command when manual update is required.""" - mock_update_service.check_for_updates.return_value = { - "has_update": True, - "current_version": "1.0.0", - "latest_version": "1.1.0", - "method": "homebrew", - "supports_auto_update": False, - } - mock_update_service.get_update_instructions.return_value = "brew upgrade kilm" - - result = runner.invoke(check_app) - - assert result.exit_code == 1 - assert "Manual update required" in result.stdout - assert "brew upgrade kilm" in result.stdout - - -class TestUpdateInfoCommand: - """Test the 'kilm update info' command.""" - - def test_info_display(self, runner, mock_update_service, mock_version): - """Test info command display.""" - mock_update_service.check_for_updates.return_value = { - "has_update": False, - "current_version": "1.0.0", - "latest_version": "1.0.0", - "method": "pip", - "supports_auto_update": True, - } - mock_update_service.get_installation_method.return_value = "pip" - mock_update_service.can_auto_update.return_value = True - mock_update_service.get_update_instructions.return_value = ( - "pip install --upgrade kilm" - ) - - result = runner.invoke(info_app) +class TestInfo: + """`kilm update info`.""" + def test_up_to_date(self, installation: FakeInstallation) -> None: + """The panel shows version, installation details, and the commands.""" + installation.latest = "1.0.0" + result = kilm_update("info") assert result.exit_code == 0 - assert "Version Information" in result.stdout - assert "Installation Details" in result.stdout + assert "Current version: 1.0.0 (latest)" in result.stdout + assert "Method: pipx" in result.stdout + assert "Update command: pipx upgrade kilm" in result.stdout assert "Available Commands" in result.stdout - def test_info_with_update_available( - self, runner, mock_update_service, mock_version - ): - """Test info command when update is available.""" - mock_update_service.check_for_updates.return_value = { - "has_update": True, - "current_version": "1.0.0", - "latest_version": "1.1.0", - "method": "pip", - "supports_auto_update": True, - } - mock_update_service.get_installation_method.return_value = "pip" - mock_update_service.can_auto_update.return_value = True - mock_update_service.get_update_instructions.return_value = ( - "pip install --upgrade kilm" - ) - - result = runner.invoke(info_app) - + def test_update_available(self, installation: FakeInstallation) -> None: + """A newer release is shown next to the current version.""" + result = kilm_update("info", "--force-check") assert result.exit_code == 0 assert "1.1.0 available" in result.stdout + assert installation.use_cache_calls == [False] - def test_info_force_check(self, runner, mock_update_service, mock_version): - """Test info command with force check flag.""" - mock_update_service.check_for_updates.return_value = { - "has_update": False, - "current_version": "1.0.0", - "latest_version": "1.0.0", - "method": "pip", - "supports_auto_update": True, - } - mock_update_service.get_installation_method.return_value = "pip" - mock_update_service.can_auto_update.return_value = True - mock_update_service.get_update_instructions.return_value = ( - "pip install --upgrade kilm" - ) - - result = runner.invoke(info_app, ["--force-check"]) - - assert result.exit_code == 0 - # Verify cache was bypassed - mock_update_service.check_for_updates.assert_called_with(use_cache=False) - - -class TestUpdatePerformCommand: - """Test the 'kilm update perform' command.""" - - def test_perform_successful_update(self, runner, mock_update_service, mock_version): - """Test successful update performance.""" - mock_update_service.can_auto_update.return_value = True - mock_update_service.check_for_updates.return_value = { - "has_update": True, - "current_version": "1.0.0", - "latest_version": "1.1.0", - } - mock_update_service.perform_update.return_value = ( - True, - "Update completed successfully!", - ) - result = runner.invoke(perform_app) +class TestPerform: + """`kilm update perform`.""" + def test_installs_the_latest_release(self, installation: FakeInstallation) -> None: + """An available update is installed with the method's installer.""" + result = kilm_update("perform") assert result.exit_code == 0 + assert "BREAKING CHANGE NOTICE" in result.stdout + assert "kilm sync" in result.stdout + assert "Updating from 1.0.0 to 1.1.0" in result.stdout assert "Update completed successfully!" in result.stdout + assert installation.upgrades == [["pipx", "upgrade", "kilm"]] - def test_perform_update_failed(self, runner, mock_update_service, mock_version): - """Test failed update performance.""" - mock_update_service.can_auto_update.return_value = True - mock_update_service.check_for_updates.return_value = { - "has_update": True, - "current_version": "1.0.0", - "latest_version": "1.1.0", - } - mock_update_service.perform_update.return_value = (False, "Update failed") - - result = runner.invoke(perform_app) - + def test_installer_failure(self, installation: FakeInstallation) -> None: + """A failed installer exits 1 with its error and the manual command.""" + installation.upgrade_error = "pipx: network unreachable" + result = kilm_update("perform") assert result.exit_code == 1 - assert "Update failed" in result.stdout - - def test_perform_no_auto_update_support( - self, runner, mock_update_service, mock_version - ): - """Test perform command when auto-update is not supported.""" - mock_update_service.can_auto_update.return_value = False - mock_update_service.get_installation_method.return_value = "homebrew" - mock_update_service.get_update_instructions.return_value = "brew upgrade kilm" - - result = runner.invoke(perform_app) - + assert "Update failed: pipx: network unreachable" in result.stdout + assert "Try manually: pipx upgrade kilm" in result.stdout + + @pytest.mark.parametrize("dry_run", [False, True]) + def test_unsupported_method( + self, installation: FakeInstallation, *, dry_run: bool + ) -> None: + """Methods without an installer exit 1, in a dry run too.""" + installation.method = "homebrew" + result = kilm_update("perform", *(["--dry-run"] if dry_run else [])) assert result.exit_code == 1 assert "Automatic update not supported" in result.stdout - assert "brew upgrade kilm" in result.stdout - - def test_perform_no_update_needed(self, runner, mock_update_service, mock_version): - """Test perform command when no update is needed.""" - mock_update_service.can_auto_update.return_value = True - mock_update_service.check_for_updates.return_value = { - "has_update": False, - "current_version": "1.0.0", - "latest_version": "1.0.0", - } - - result = runner.invoke(perform_app) + assert "Installation method: homebrew" in result.stdout + assert installation.upgrades == [] + def test_up_to_date(self, installation: FakeInstallation) -> None: + """Nothing is installed when the latest release is installed.""" + installation.latest = "1.0.0" + result = kilm_update("perform") assert result.exit_code == 0 - assert "Already using the latest version" in result.stdout - - def test_perform_force_update(self, runner, mock_update_service, mock_version): - """Test perform command with force flag.""" - mock_update_service.can_auto_update.return_value = True - mock_update_service.perform_update.return_value = ( - True, - "Update completed successfully!", - ) - - result = runner.invoke(perform_app, ["--force"]) + assert "Already using the latest version (v1.0.0)" in result.stdout + assert installation.upgrades == [] + def test_force_reinstalls(self, installation: FakeInstallation) -> None: + """--force installs without asking PyPI whether an update exists.""" + installation.latest = "1.0.0" + result = kilm_update("perform", "--force") assert result.exit_code == 0 - # Should skip the update check when forced - mock_update_service.perform_update.assert_called_with( - target_version=None, force=True, dry_run=False - ) - - def test_perform_dry_run(self, runner, mock_update_service, mock_version): - """Test perform command in dry run mode.""" - mock_update_service.perform_update.return_value = (True, "Dry run message") - - result = runner.invoke(perform_app, ["--dry-run"]) + assert installation.use_cache_calls == [] + assert installation.upgrades == [["pipx", "upgrade", "kilm"]] + def test_dry_run(self, installation: FakeInstallation) -> None: + """A dry run shows the installer command and installs nothing.""" + result = kilm_update("perform", "--dry-run", "-t", "1.0.5") assert result.exit_code == 0 + assert "pipx install --force kilm==1.0.5" in result.stdout assert "Dry run completed" in result.stdout - mock_update_service.perform_update.assert_called_with( - target_version=None, force=False, dry_run=True - ) - - def test_perform_target_version(self, runner, mock_update_service, mock_version): - """Test perform command with specific target version.""" - mock_update_service.can_auto_update.return_value = True - mock_update_service.check_for_updates.return_value = { - "has_update": True, - "current_version": "1.0.0", - "latest_version": "1.1.0", - } - mock_update_service.perform_update.return_value = ( - True, - "Update completed successfully!", - ) - - result = runner.invoke(perform_app, ["--target-version", "1.0.5"]) + assert installation.upgrades == [] + def test_target_version_downgrade(self, installation: FakeInstallation) -> None: + """A target version is installed even when it is older.""" + installation.latest = "1.0.0" + result = kilm_update("perform", "--target-version", "0.9.0") assert result.exit_code == 0 - mock_update_service.perform_update.assert_called_with( - target_version="1.0.5", force=False, dry_run=False - ) - - def test_perform_no_cache(self, runner, mock_update_service, mock_version): - """Test perform command with no-cache flag.""" - mock_update_service.can_auto_update.return_value = True - mock_update_service.check_for_updates.return_value = { - "has_update": False, - "current_version": "1.0.0", - "latest_version": "1.0.0", - } - - result = runner.invoke(perform_app, ["--no-cache"]) - + assert "Updating from 1.0.0 to 0.9.0" in result.stdout + assert installation.upgrades == [["pipx", "install", "--force", "kilm==0.9.0"]] + + def test_target_version_already_installed( + self, installation: FakeInstallation + ) -> None: + """Asking for the installed version changes nothing without --force.""" + result = kilm_update("perform", "-t", "1.0") assert result.exit_code == 0 - # Should bypass cache - mock_update_service.check_for_updates.assert_called_with(use_cache=False) - - def test_deprecation_notice_displayed( - self, runner, mock_update_service, mock_version - ): - """Test that deprecation notice is displayed.""" - mock_update_service.can_auto_update.return_value = True - mock_update_service.check_for_updates.return_value = { - "has_update": False, - "current_version": "1.0.0", - "latest_version": "1.0.0", - } - - result = runner.invoke(perform_app) + assert "Already using version 1.0.0" in result.stdout + assert installation.upgrades == [] - assert "BREAKING CHANGE NOTICE" in result.stdout - assert "kilm sync" in result.stdout + def test_invalid_target_version(self, installation: FakeInstallation) -> None: + """A target that is no version number is a usage error.""" + result = kilm_update("perform", "-t", "latest-please") + assert result.exit_code == 2 + assert installation.upgrades == [] + def test_no_cache(self, installation: FakeInstallation) -> None: + """--no-cache asks PyPI even when a recent answer is cached.""" + kilm_update("perform", "--no-cache") + assert installation.use_cache_calls == [False] -class TestUpdateCommandGroup: - """Test the update command group integration.""" - def test_update_app_help(self, runner): - """Test that the update command group shows help correctly.""" - result = runner.invoke(update_app, ["--help"]) +class TestGroup: + """The `kilm update` group itself.""" - assert result.exit_code == 0 + @pytest.mark.parametrize("args", [[], ["--help"]]) + def test_help_lists_the_subcommands(self, args: list[str]) -> None: + """Without a subcommand, or with --help, the group shows its help.""" + result = kilm_update(*args) assert "Update KiLM CLI to the latest version" in result.stdout - assert "check" in result.stdout - assert "info" in result.stdout - assert "perform" in result.stdout + for name in ("check", "info", "perform"): + assert name in result.stdout - def test_update_app_no_args_shows_help(self, runner): - """Test that running update with no args shows help.""" - result = runner.invoke(update_app, []) - - # Different Typer/Click versions may return 0 or 2 for no_args_is_help - assert result.exit_code in [0, 2] + @pytest.mark.parametrize("name", ["check", "info", "perform"]) + def test_subcommand_help(self, name: str) -> None: + """Every subcommand documents its usage.""" + result = kilm_update(name, "--help") + assert result.exit_code == 0 assert "Usage:" in result.stdout - assert "Commands" in result.stdout # Rich formatting uses "╭─ Commands ───" - - def test_subcommand_help_messages(self, runner): - """Test that each subcommand has proper help.""" - subcommands = ["check", "info", "perform"] - - for subcommand in subcommands: - result = runner.invoke(update_app, [subcommand, "--help"]) - assert result.exit_code == 0 - assert "Usage:" in result.stdout - - -class TestIntegrationScenarios: - """Test realistic integration scenarios.""" - - def test_uv_tool_update_workflow(self, runner): - """Test complete workflow for UV tool installation.""" - with ( - patch( - "kicad_lib_manager.commands.update.check.UpdateService" - ) as mock_service_class, - patch("importlib.metadata.version") as mock_version, - ): - mock_version.return_value = "1.0.0" - mock_service = MagicMock() - mock_service_class.return_value = mock_service - - # Mock UV tool installation - mock_service.check_for_updates.return_value = { - "has_update": True, - "current_version": "1.0.0", - "latest_version": "1.1.0", - "method": "uv", - "supports_auto_update": True, - } - mock_service.get_update_instructions.return_value = "uv tool upgrade kilm" - - # Test check command - result = runner.invoke(check_app) - assert result.exit_code == 1 - assert "Update available!" in result.stdout - assert "kilm update perform" in result.stdout - - def test_pipx_installation_workflow(self, runner): - """Test complete workflow for pipx installation.""" - with ( - patch( - "kicad_lib_manager.commands.update.info.UpdateService" - ) as mock_service_class, - patch("importlib.metadata.version") as mock_version, - ): - mock_version.return_value = "1.0.0" - mock_service = MagicMock() - mock_service_class.return_value = mock_service - - # Mock pipx installation - mock_service.check_for_updates.return_value = { - "has_update": False, - "current_version": "1.0.0", - "latest_version": "1.0.0", - "method": "pipx", - "supports_auto_update": True, - } - mock_service.get_installation_method.return_value = "pipx" - mock_service.can_auto_update.return_value = True - mock_service.get_update_instructions.return_value = "pipx upgrade kilm" - - # Test info command - result = runner.invoke(info_app) - assert result.exit_code == 0 - assert "pipx" in result.stdout - assert "pipx upgrade kilm" in result.stdout - - def test_homebrew_manual_update_workflow(self, runner): - """Test workflow for homebrew installation requiring manual update.""" - with ( - patch( - "kicad_lib_manager.commands.update.perform.UpdateService" - ) as mock_service_class, - patch("importlib.metadata.version") as mock_version, - ): - mock_version.return_value = "1.0.0" - mock_service = MagicMock() - mock_service_class.return_value = mock_service - - # Mock homebrew installation (no auto-update support) - mock_service.can_auto_update.return_value = False - mock_service.get_installation_method.return_value = "homebrew" - mock_service.get_update_instructions.return_value = ( - "Homebrew package not yet available (planned for future)" - ) - - # Test perform command - result = runner.invoke(perform_app) - assert result.exit_code == 1 - assert "Automatic update not supported" in result.stdout - assert "homebrew" in result.stdout diff --git a/tests/test_update_service.py b/tests/test_update_service.py index 85bea9e..7bd7ca6 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -1,433 +1,148 @@ -""" -Tests for the update service functionality including installation detection and update operations. -""" - -import sys -from pathlib import Path, PureWindowsPath -from unittest.mock import Mock, patch +"""Tests for checking for and installing KiLM releases.""" import pytest -import requests -from kicad_lib_manager.services.update_service import ( - InstallationDetector, - UpdateManager, - UpdateService, - detect_installation_method, -) +from kicad_lib_manager.services import update_service +from kicad_lib_manager.services.install_method import InstallMethod +from kicad_lib_manager.services.pypi_version import PyPIVersionChecker +from kicad_lib_manager.services.update_service import UpdateService -class TestInstallationDetection: - """Test installation method detection logic using the modular detector.""" +class StaticVersionSource: + """A latest-release source that answers without the network.""" - @pytest.fixture(autouse=True) - def _clear_detection_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Keep detection hermetic: CI runners and dev shells may export these. + def __init__(self, latest: str | None) -> None: + """Answer every check with `latest`.""" + self.latest = latest + self.use_cache_calls: list[bool] = [] - setup-uv on Windows runners sets UV_TOOL_DIR, for example, which made - the venv tests detect "uv" instead of "pip-venv". - """ - for var in ( - "PIPX_HOME", - "CONDA_DEFAULT_ENV", - "UV_TOOL_DIR", - "UV_TOOL_BIN_DIR", - "VIRTUAL_ENV", - ): - monkeypatch.delenv(var, raising=False) + def check_latest_version(self, *, use_cache: bool = True) -> str | None: + """Record the cache choice and return the configured version.""" + self.use_cache_calls.append(use_cache) + return self.latest - def test_detect_pipx_installation_path_pattern(self): - """Test detection of pipx installation via path pattern.""" - detector = InstallationDetector( - executable_path=Path("/Users/user/.local/share/pipx/venvs/kilm/bin/python") - ) - assert detector.detect_pipx() is True - assert detector.detect() == "pipx" - def test_detect_pipx_installation_with_env_var(self, monkeypatch): - """Test detection of pipx installation via environment variable.""" - monkeypatch.setenv("PIPX_HOME", "/Users/user/.local/share/pipx") +def make_service( + method: InstallMethod = "pipx", latest: str | None = "1.1.0" +) -> UpdateService: + """An UpdateService for an installed 1.0.0 that sees `latest` on PyPI.""" + return UpdateService( + "1.0.0", method=method, version_checker=StaticVersionSource(latest) + ) - detector = InstallationDetector(executable_path=Path("/opt/pipx/bin/python")) - assert detector.detect_pipx() is True - assert detector.detect() == "pipx" - def test_detect_conda_installation_path_pattern(self): - """Test detection of conda installation via path pattern.""" - detector = InstallationDetector( - executable_path=Path("/opt/miniconda3/envs/kilm/bin/python") - ) - assert detector.detect_conda() is True - assert detector.detect() == "conda" +@pytest.fixture +def upgrades(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]: + """Record installer commands instead of running them; they succeed.""" + commands: list[list[str]] = [] - def test_detect_conda_installation_with_env_var(self, monkeypatch): - """Test detection of conda installation via environment variable.""" - monkeypatch.setenv("CONDA_DEFAULT_ENV", "kilm-env") + def fake_run_upgrade(command: list[str]) -> str | None: + commands.append(command) + return None - detector = InstallationDetector(executable_path=Path("/usr/bin/python")) - assert detector.detect_conda() is True - assert detector.detect() == "conda" + monkeypatch.setattr(update_service, "run_upgrade", fake_run_upgrade) + return commands - def test_detect_uv_installation_env_vars(self, monkeypatch): - """Test detection of UV installation via environment variables.""" - monkeypatch.setenv("UV_TOOL_DIR", "/Users/user/.local/share/uv") - detector = InstallationDetector(executable_path=Path("/usr/bin/python")) - assert detector.detect_uv() is True - assert detector.detect() == "uv" - - def test_detect_uv_installation_path_patterns(self): - """Test detection of UV installation via path patterns.""" - detector = InstallationDetector( - executable_path=Path("/Users/user/.local/share/uv/tools/kilm/bin/python") - ) - assert detector.detect_uv() is True - assert detector.detect() == "uv" +class TestCheckForUpdates: + """Comparing the installed version with the latest release.""" @pytest.mark.parametrize( - ("executable", "expected"), - [ - (r"C:\Users\user\pipx\venvs\kilm\Scripts\python.exe", "pipx"), - ( - r"C:\Users\user\AppData\Roaming\uv\tools\kilm\Scripts\python.exe", - "uv", - ), - ], + ("latest", "has_update"), + [("1.1.0", True), ("1.0.0", False), ("0.9.0", False), (None, False)], ) - def test_detect_windows_path_patterns(self, executable: str, expected: str) -> None: - """Path patterns match Windows executables with backslash separators.""" - detector = InstallationDetector( - executable_path=PureWindowsPath(executable), platform_name="nt" - ) - assert detector.detect() == expected - - def test_detect_pip_venv_installation_env_var(self, monkeypatch): - """Test detection of pip in virtual environment via VIRTUAL_ENV.""" - monkeypatch.setenv("VIRTUAL_ENV", "/path/to/venv") - - detector = InstallationDetector( - executable_path=Path("/path/to/venv/bin/python") - ) - assert detector.detect_virtual_env() is True - assert detector.detect() == "pip-venv" - - def test_detect_pip_venv_installation_prefix_check(self, monkeypatch): - """Test detection of pip in venv via sys.prefix check.""" - # Mock sys.prefix and base_prefix to simulate virtual environment - monkeypatch.setattr(sys, "prefix", "/path/to/venv") - monkeypatch.setattr(sys, "base_prefix", "/usr/local") - - detector = InstallationDetector( - executable_path=Path("/path/to/venv/bin/python") - ) - assert detector.detect_virtual_env() is True - assert detector.detect() == "pip-venv" - - def test_platform_detection(self): - """Test platform detection methods.""" - # Test Windows detection - windows_detector = InstallationDetector(platform_name="nt") - assert windows_detector.is_windows() is True - assert windows_detector.is_unix_like() is False - - # Test Unix detection - unix_detector = InstallationDetector(platform_name="posix") - assert unix_detector.is_windows() is False - assert unix_detector.is_unix_like() is True - - def test_priority_order(self): - """Test that detection follows correct priority order.""" - # pipx should take priority over pip-venv - with ( - patch.object(sys, "prefix", "/path/to/venv"), - patch.object(sys, "base_prefix", "/usr/local"), - ): - detector = InstallationDetector( - executable_path=Path( - "/Users/user/.local/share/pipx/venvs/kilm/bin/python" - ) - ) - assert detector.detect() == "pipx" # pipx wins over pip-venv + def test_newer_release(self, latest: str | None, *, has_update: bool) -> None: + """Only a newer release is an update; an unknown one is not.""" + status = make_service(latest=latest).check_for_updates() + assert status == { + "has_update": has_update, + "current_version": "1.0.0", + "latest_version": latest, + "method": "pipx", + "supports_auto_update": True, + } + + def test_invalid_versions_are_no_update(self) -> None: + """A version string packaging cannot parse never reports an update.""" + assert not make_service(latest="not-a-version").check_for_updates()[ + "has_update" + ] - def test_legacy_detect_installation_method_function(self, monkeypatch): - """Test that the legacy function still works.""" + def test_cache_choice_reaches_the_version_source(self) -> None: + """use_cache is passed through, so --force really asks PyPI.""" + source = StaticVersionSource("1.1.0") + service = UpdateService("1.0.0", method="pip", version_checker=source) + service.check_for_updates() + service.check_for_updates(use_cache=False) + assert source.use_cache_calls == [True, False] + + def test_default_checker_identifies_the_installed_version(self) -> None: + """PyPI requests carry the running kilm version in the User-Agent.""" + checker = UpdateService("1.0.0", method="pip").version_checker + assert isinstance(checker, PyPIVersionChecker) + assert checker.user_agent.startswith("KiLM/1.0.0 ") + + +class TestPerformUpdate: + """Running the installer.""" + + def test_installs_the_latest_release(self, upgrades: list[list[str]]) -> None: + """Without a target the installer upgrades to the latest release.""" + result = make_service("pipx").perform_update() + assert result.success + assert upgrades == [["pipx", "upgrade", "kilm"]] + + def test_installs_the_target_version(self, upgrades: list[list[str]]) -> None: + """A target version is installed exactly, even when it is older.""" + result = make_service("uv").perform_update("0.9.0") + assert result.success + assert upgrades == [["uv", "tool", "install", "kilm==0.9.0"]] + + def test_failure_explains_and_suggests_the_manual_command( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The installer's error and the command to retry by hand are reported.""" monkeypatch.setattr( - sys, "executable", "/Users/user/.local/share/pipx/venvs/kilm/bin/python" + update_service, + "run_upgrade", + lambda _command: "No matching distribution found for kilm==9.9.9", ) - result = detect_installation_method() - assert result == "pipx" - - -class TestUpdateManager: - """Test UpdateManager functionality.""" - - @pytest.fixture - def update_manager(self): - """Create an UpdateManager instance for testing.""" - with patch( - "kicad_lib_manager.services.update_service.detect_installation_method" - ) as mock_detect: - mock_detect.return_value = "pip" - return UpdateManager("1.0.0") - - def test_version_comparison(self, update_manager): - """Test version comparison logic.""" - assert update_manager.is_newer_version_available("1.1.0") is True - assert update_manager.is_newer_version_available("1.0.0") is False - assert update_manager.is_newer_version_available("0.9.0") is False - - def test_invalid_version_comparison(self, update_manager): - """Test handling of invalid version strings.""" - assert update_manager.is_newer_version_available("invalid-version") is False - - def test_can_auto_update_supported_methods(self, monkeypatch): - """Test auto-update support for supported installation methods.""" - for method in ["pipx", "pip", "pip-venv", "uv"]: - with patch( - "kicad_lib_manager.services.update_service.detect_installation_method" - ) as mock_detect: - mock_detect.return_value = method - manager = UpdateManager("1.0.0") - assert manager.can_auto_update() is True - - def test_can_auto_update_unsupported_methods(self, monkeypatch): - """Test auto-update support for unsupported installation methods.""" - for method in ["conda", "homebrew"]: - with patch( - "kicad_lib_manager.services.update_service.detect_installation_method" - ) as mock_detect: - mock_detect.return_value = method - manager = UpdateManager("1.0.0") - assert manager.can_auto_update() is False - - def test_get_update_instructions(self): - """Test getting update instructions for different installation methods.""" - test_cases = [ - ("pipx", "pipx upgrade kilm"), - ("pip", "pip install --upgrade kilm"), - ("pip-venv", "pip install --upgrade kilm"), - ("uv", "uv tool upgrade kilm"), - ("conda", "Conda package not yet available (planned for future)"), - ("homebrew", "Homebrew package not yet available (planned for future)"), - ] - - for method, expected in test_cases: - with patch( - "kicad_lib_manager.services.update_service.detect_installation_method" - ) as mock_detect: - mock_detect.return_value = method - manager = UpdateManager("1.0.0") - assert manager.get_update_instruction() == expected - - -class TestUpdateService: - """Test UpdateService functionality.""" - - @pytest.fixture - def update_service(self): - """Create an UpdateService instance for testing.""" - with patch( - "kicad_lib_manager.services.update_service.detect_installation_method" - ) as mock_detect: - mock_detect.return_value = "pip" - return UpdateService("1.0.0") - - def test_check_for_updates_no_update_available(self, update_service): - """Test check for updates when no update is available.""" - with ( - patch.object(update_service.manager, "check_latest_version") as mock_check, - patch.object( - update_service.manager, "is_newer_version_available" - ) as mock_newer, - ): - mock_check.return_value = "1.0.0" - mock_newer.return_value = False - - result = update_service.check_for_updates() - - assert result["has_update"] is False - assert result["current_version"] == "1.0.0" - assert result["latest_version"] == "1.0.0" - assert result["method"] == "pip" - assert "supports_auto_update" in result - - def test_check_for_updates_update_available(self, update_service): - """Test check for updates when update is available.""" - with ( - patch.object(update_service.manager, "check_latest_version") as mock_check, - patch.object( - update_service.manager, "is_newer_version_available" - ) as mock_newer, - ): - mock_check.return_value = "1.1.0" - mock_newer.return_value = True - - result = update_service.check_for_updates() - - assert result["has_update"] is True - assert result["current_version"] == "1.0.0" - assert result["latest_version"] == "1.1.0" - assert result["method"] == "pip" - assert "supports_auto_update" in result - - def test_check_for_updates_no_latest_version(self, update_service): - """Test check for updates when latest version cannot be determined.""" - with patch.object(update_service.manager, "check_latest_version") as mock_check: - mock_check.return_value = None - - result = update_service.check_for_updates() - - assert result["has_update"] is False - assert result["current_version"] == "1.0.0" - assert result["latest_version"] is None - assert result["method"] == "pip" - - def test_show_update_notification_no_update(self, update_service): - """Test showing update notification when no update is available.""" - with ( - patch.object(update_service, "check_for_updates") as mock_check, - patch("rich.console.Console") as mock_console, - ): - mock_check.return_value = { - "has_update": False, - "current_version": "1.0.0", - "latest_version": "1.0.0", - "method": "pip", - "supports_auto_update": True, - } - - result = update_service.show_update_notification() - - assert result is False - # Verify the console was used to print the "latest version" message - mock_console.assert_called() - - def test_show_update_notification_update_available(self, update_service): - """Test showing update notification when update is available.""" - with ( - patch.object(update_service, "check_for_updates") as mock_check, - patch("rich.console.Console") as mock_console, - patch("rich.panel.Panel") as mock_panel, - ): - mock_check.return_value = { - "has_update": True, - "current_version": "1.0.0", - "latest_version": "1.1.0", - "method": "pip", - "supports_auto_update": True, - } - - result = update_service.show_update_notification() - - assert result is True - # Verify console and panel were used - mock_console.assert_called() - mock_panel.assert_called() - - def test_perform_update_dry_run(self, update_service): - """Test dry run mode for updates.""" - with patch.object(update_service, "_show_dry_run_info") as mock_dry_run: - mock_dry_run.return_value = (True, "Dry run message") - - success, message = update_service.perform_update(dry_run=True) - - assert success is True - assert message == "Dry run message" - mock_dry_run.assert_called_once_with(None) - - def test_perform_update_no_update_needed(self, update_service): - """Test performing update when no update is needed.""" - with patch.object(update_service, "check_for_updates") as mock_check: - mock_check.return_value = {"has_update": False} - - success, message = update_service.perform_update() - - assert success is True - assert message == "Already using the latest version!" - - def test_perform_update_force_when_no_update_needed(self, update_service): - """Test performing forced update when no update is needed.""" - with ( - patch.object(update_service, "check_for_updates") as mock_check, - patch.object(update_service.manager, "perform_update") as mock_perform, - ): - mock_check.return_value = {"has_update": False} - mock_perform.return_value = (True, "Update successful") - - success, message = update_service.perform_update(force=True) - - assert success is True - assert message == "Update successful" - mock_perform.assert_called_once() - - def test_dry_run_info_display(self, update_service): - """Test dry run information display.""" - with ( - patch("rich.console.Console") as mock_console, - patch("rich.panel.Panel") as mock_panel, - ): - success, message = update_service._show_dry_run_info("1.1.0") - - # Should return whether auto-update is supported - assert isinstance(success, bool) - assert "Dry run completed" in message - - # Verify console and panel were used - mock_console.assert_called() - mock_panel.assert_called() - - -class TestPyPIVersionChecker: - """Test PyPI version checking functionality.""" - - @pytest.fixture - def version_checker(self): - """Create a PyPIVersionChecker instance for testing.""" - from kicad_lib_manager.services.update_service import PyPIVersionChecker - - return PyPIVersionChecker("kilm", "1.0.0") - - def test_cache_file_location(self, version_checker): - """Test that cache file is created in the correct location.""" - assert version_checker.cache_file.name == "version_check.json" - assert "kilm" in str(version_checker.cache_file) - - @patch("requests.get") - def test_successful_version_check(self, mock_get, version_checker): - """Test successful version check from PyPI.""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = {"info": {"version": "1.1.0"}} - mock_response.headers.get.return_value = "etag123" - mock_get.return_value = mock_response - - version = version_checker.check_latest_version() - - assert version == "1.1.0" - - @patch("requests.get") - def test_failed_version_check(self, mock_get, version_checker): - """Test failed version check from PyPI.""" - mock_get.side_effect = requests.RequestException("Network error") - - version = version_checker.check_latest_version() - - assert version is None - - @patch("requests.get") - def test_not_modified_response(self, mock_get, version_checker): - """Test handling of 304 Not Modified response.""" - # First, create cached data with correct key names - import time - - version_checker._save_cache( - {"version": "1.0.5", "etag": "etag123", "timestamp": time.time()} + result = make_service("pipx").perform_update("9.9.9") + assert not result.success + assert result.message == ( + "No matching distribution found for kilm==9.9.9\n" + "Try manually: pipx install --force kilm==9.9.9" ) - mock_response = Mock() - mock_response.status_code = 304 - mock_get.return_value = mock_response - - version = version_checker.check_latest_version() - - assert version == "1.0.5" + def test_manual_methods_install_nothing(self, upgrades: list[list[str]]) -> None: + """Methods without an installer command only get advice.""" + result = make_service("homebrew").perform_update() + assert not result.success + assert "Manual update required" in result.message + assert upgrades == [] + + +class TestShowUpdateNotification: + """The update notice printed by the root command.""" + + def test_up_to_date(self, capsys: pytest.CaptureFixture[str]) -> None: + """Up to date prints a confirmation, or nothing when quiet.""" + service = make_service(latest="1.0.0") + assert not service.show_update_notification(quiet=True) + assert not capsys.readouterr().out + assert not service.show_update_notification() + assert "You are using the latest version" in capsys.readouterr().out + + def test_update_available(self, capsys: pytest.CaptureFixture[str]) -> None: + """A newer release is announced with how to get it.""" + assert make_service("pipx").show_update_notification(quiet=True) + output = capsys.readouterr().out + assert "1.0.0 → Latest: 1.1.0" in output + assert "Run kilm update perform to update" in output + + def test_manual_update_instruction( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Installs KiLM cannot update show the manual instruction.""" + assert make_service("nix").show_update_notification(quiet=True) + assert "Update manually: nix flake update" in capsys.readouterr().out diff --git a/uv.lock b/uv.lock index ca5f033..0fb3d7e 100644 --- a/uv.lock +++ b/uv.lock @@ -213,7 +213,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -297,6 +297,7 @@ dependencies = [ { name = "requests" }, { name = "rich" }, { name = "typer" }, + { name = "typing-extensions" }, ] [package.dev-dependencies] @@ -319,6 +320,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.4" }, { name = "rich", specifier = ">=14.0.0" }, { name = "typer", specifier = ">=0.17.4" }, + { name = "typing-extensions", specifier = ">=4.4.0" }, ] [package.metadata.requires-dev]