From f63845830f719129facc8ca5d9312f1e18c6aa31 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:21:19 +0200 Subject: [PATCH 001/105] chore: maximum ruff and ty strictness with a module-size gate Ruff selects ALL with complexity limits (mccabe 10, pylint defaults stated explicitly, four preview complexity rules opted in one by one) and bans relative imports; the only global ignores are COM812 (formatter conflict) and CPY001 (licensing lives in LICENSE). Google docstring convention. Tests may use asserts and literal expected values. ty runs every rule at error with strict equality and generic narrowing and ignores `type: ignore` comments. tests/test_repository_limits.py holds what no lint rule covers: modules stay within 600 lines, and every suppression names its rule and a reason. The tree does not pass these gates yet; follow-up commits refactor it. --- AGENTS.md | 12 ++-- .../content/docs/community/development.mdx | 2 +- pyproject.toml | 71 ++++++++++++++----- tests/test_repository_limits.py | 58 +++++++++++++++ 4 files changed, 121 insertions(+), 22 deletions(-) create mode 100644 tests/test_repository_limits.py diff --git a/AGENTS.md b/AGENTS.md index 1145ff5..9342020 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. | @@ -54,12 +54,16 @@ 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. - Use `pathlib.Path` for paths and keep behavior identical on Windows, macOS, 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/pyproject.toml b/pyproject.toml index a2f27f6..7735a12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,32 +93,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"] diff --git a/tests/test_repository_limits.py b/tests/test_repository_limits.py new file mode 100644 index 0000000..baaa6a9 --- /dev/null +++ b/tests/test_repository_limits.py @@ -0,0 +1,58 @@ +"""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``. +""" + +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" +) + + +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}" From 91d65ed0b71a869a14f46bd2aded704648b71608 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:23:45 +0200 Subject: [PATCH 002/105] fix: decide Rich color mode when output is written, not at import Every command module created a Rich Console at import. A Console fixes its color system and terminal mode when constructed, so importing the app from a terminal (pytest -s, or a Nix check phase) kept ANSI codes in output later redirected into CliRunner: `script -q /dev/null uv run pytest -s` failed 29 tests. kicad_lib_manager.utils.console.get_console builds a console for the current stream on each call and every command uses it. main.py no longer installs rich.traceback at import (it also created a console then, and showed locals that Typer's pretty exceptions deliberately hide). The import-zip test helpers import the app at module level again. tests/test_console.py fails if any module holds a console and checks that get_console follows a redirected stdout. --- kicad_lib_manager/commands/add_3d/command.py | 63 ++--- .../commands/add_hook/command.py | 41 +-- kicad_lib_manager/commands/config/command.py | 97 +++---- .../commands/import_zip/command.py | 39 +-- .../commands/import_zip/importer.py | 10 +- .../commands/import_zip/kicad_cli.py | 10 +- .../commands/import_zip/target.py | 31 ++- kicad_lib_manager/commands/init/command.py | 31 ++- .../commands/list_libraries/command.py | 29 ++- kicad_lib_manager/commands/pin/command.py | 41 +-- kicad_lib_manager/commands/setup/command.py | 131 +++++----- kicad_lib_manager/commands/status/command.py | 89 +++---- kicad_lib_manager/commands/sync/command.py | 67 ++--- .../commands/template/command.py | 236 ++++++++++-------- kicad_lib_manager/commands/unpin/command.py | 51 ++-- kicad_lib_manager/commands/update/check.py | 21 +- kicad_lib_manager/commands/update/info.py | 7 +- kicad_lib_manager/commands/update/perform.py | 45 ++-- kicad_lib_manager/main.py | 13 +- kicad_lib_manager/utils/console.py | 21 ++ tests/import_zip/helpers.py | 8 +- tests/test_console.py | 56 +++++ 22 files changed, 636 insertions(+), 501 deletions(-) create mode 100644 kicad_lib_manager/utils/console.py create mode 100644 tests/test_console.py diff --git a/kicad_lib_manager/commands/add_3d/command.py b/kicad_lib_manager/commands/add_3d/command.py index bb8242e..dfd8cd1 100644 --- a/kicad_lib_manager/commands/add_3d/command.py +++ b/kicad_lib_manager/commands/add_3d/command.py @@ -6,7 +6,8 @@ from typing import Annotated import typer -from rich.console import Console + +from kicad_lib_manager.utils.console import get_console from ...services.config_service import Config from ...utils.metadata import ( @@ -17,8 +18,6 @@ write_cloud_metadata, ) -console = Console() - def add_3d( name: Annotated[ @@ -74,7 +73,7 @@ def add_3d( # Use current directory if not specified directory = Path.cwd().resolve() if directory is None else directory.resolve() - console.print( + get_console().print( f"[bold cyan]Adding cloud-based 3D models directory:[/bold cyan] {directory}" ) @@ -82,39 +81,41 @@ def add_3d( metadata = read_cloud_metadata(directory) if metadata and not force: - console.print( + get_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]") + get_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( + get_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]") + get_console().print( + f"Overriding with provided name: [blue]{library_name}[/blue]" + ) if description: library_description = description - console.print( + get_console().print( f"Overriding with provided description: [blue]{library_description}[/blue]" ) if env_var: library_env_var = env_var - console.print( + get_console().print( f"Overriding with provided environment variable: [yellow]{library_env_var}[/yellow]" ) elif no_env_var: library_env_var = None - console.print( + get_console().print( "[yellow]Disabling environment variable as requested[/yellow]" ) @@ -128,15 +129,17 @@ def add_3d( metadata["env_var"] = None metadata["updated_with"] = "kilm" write_cloud_metadata(directory, metadata) - console.print("[green]Updated metadata file with new information[/green]") + get_console().print( + "[green]Updated metadata file with new information[/green]" + ) else: # Create a new metadata file if metadata and force: - console.print( + get_console().print( f"[yellow]Overwriting existing metadata file[/yellow] ({CLOUD_METADATA_FILE})" ) else: - console.print( + get_console().print( f"[green]Creating new metadata file[/green] ({CLOUD_METADATA_FILE})" ) @@ -160,7 +163,7 @@ def add_3d( # Write metadata file write_cloud_metadata(directory, metadata) - console.print("[green]Metadata file created[/green]") + get_console().print("[green]Metadata file created[/green]") library_name = metadata["name"] library_env_var = metadata.get("env_var") @@ -176,11 +179,11 @@ def add_3d( break if not found_models: - console.print( + get_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]") + get_console().print("[red]Operation cancelled[/red]") raise typer.Exit(0) # Update metadata with actual model count @@ -204,28 +207,30 @@ def add_3d( ) config.add_library(final_library_name, str(directory), "cloud") - console.print( + get_console().print( f"[bold green]3D models directory '{final_library_name}' added successfully![/bold green]" ) - console.print(f"[blue]Path:[/blue] {directory}") + get_console().print(f"[blue]Path:[/blue] {directory}") if model_count > 0: - console.print(f"[green]Found {model_count} 3D model files[/green]") + get_console().print(f"[green]Found {model_count} 3D model files[/green]") if library_env_var: - console.print( + get_console().print( f"[yellow]Assigned environment variable:[/yellow] {library_env_var}" ) - console.print("\n[bold]You can use this directory with:[/bold]") - console.print( + get_console().print("\n[bold]You can use this directory with:[/bold]") + get_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]") + get_console().print(" [dim]# or by setting the environment variable[/dim]") + get_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( + get_console().print( "\n[bold]All registered cloud-based 3D model directories:[/bold]" ) for lib in libraries: @@ -242,11 +247,11 @@ def add_3d( pass if lib_env_var: - console.print( + get_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}") + get_console().print(f" - [cyan]{lib_name}[/cyan]: {lib_path}") except Exception as e: - console.print(f"[red]Error adding 3D models directory: {e}[/red]") + get_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_hook/command.py b/kicad_lib_manager/commands/add_hook/command.py index f7c263e..56fc48b 100644 --- a/kicad_lib_manager/commands/add_hook/command.py +++ b/kicad_lib_manager/commands/add_hook/command.py @@ -7,7 +7,8 @@ from typing import Annotated import typer -from rich.console import Console + +from kicad_lib_manager.utils.console import get_console from ...utils.git_utils import ( backup_existing_hook, @@ -16,8 +17,6 @@ merge_hook_content, ) -console = Console() - def add_hook( directory: Annotated[ @@ -43,15 +42,15 @@ def add_hook( # Determine target directory target_dir = directory if directory else Path.cwd() - console.print(f"[cyan]Adding Git hook to repository: {target_dir}[/cyan]") + get_console().print(f"[cyan]Adding Git hook to repository: {target_dir}[/cyan]") 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]") + get_console().print(f"[blue]Using hooks directory: {hooks_dir}[/blue]") except RuntimeError as e: - console.print(f"[red]Error: {e}[/red]") + get_console().print(f"[red]Error: {e}[/red]") raise typer.Exit(1) from e # Check if post-merge hook already exists @@ -59,17 +58,17 @@ def add_hook( if post_merge_hook.exists(): if not force: - console.print( + get_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]") + get_console().print("[yellow]Hook installation cancelled.[/yellow]") return # Create backup of existing hook backup_path = backup_existing_hook(post_merge_hook) - console.print( + get_console().print( f"[green]Created backup of existing hook: {backup_path}[/green]" ) @@ -79,13 +78,13 @@ def add_hook( if force: # Force overwrite - don't merge, just replace - console.print( + get_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( + get_console().print( "[cyan]Merging KiLM content with existing hook...[/cyan]" ) new_content = merge_hook_content( @@ -93,7 +92,7 @@ def add_hook( ) except (OSError, UnicodeDecodeError): - console.print( + get_console().print( "[yellow]Warning: Could not read existing hook content, overwriting...[/yellow]" ) new_content = create_kilm_hook_content() @@ -109,30 +108,32 @@ def add_hook( # Make the hook executable post_merge_hook.chmod(0o755) - console.print( + get_console().print( f"[green]Successfully installed post-merge hook at {post_merge_hook}[/green]" ) - console.print( + get_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( + get_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]") + get_console().print( + "[cyan]making future updates safe and idempotent.[/cyan]" + ) - console.print( + get_console().print( "\n[blue]Note: You may need to modify the hook script if you want to customize[/blue]" ) - console.print( + get_console().print( "[blue]the update behavior or automatically set up libraries.[/blue]" ) except Exception as e: - console.print(f"[red]Error creating hook: {str(e)}[/red]") + get_console().print(f"[red]Error creating hook: {str(e)}[/red]") raise typer.Exit(1) from e except Exception as e: - console.print(f"[red]Error: {e}[/red]") + get_console().print(f"[red]Error: {e}[/red]") raise typer.Exit(1) from e diff --git a/kicad_lib_manager/commands/config/command.py b/kicad_lib_manager/commands/config/command.py index bd136c0..cdcb4ce 100644 --- a/kicad_lib_manager/commands/config/command.py +++ b/kicad_lib_manager/commands/config/command.py @@ -8,10 +8,11 @@ from typing import Annotated import typer -from rich.console import Console from rich.panel import Panel from rich.table import Table +from kicad_lib_manager.utils.console import get_console + from ...services.config_service import Config from ...utils.metadata import ( CLOUD_METADATA_FILE, @@ -20,8 +21,6 @@ read_github_metadata, ) -console = Console() - def list_config( library_type: Annotated[ @@ -59,8 +58,8 @@ def list_config( current_library = config.get_current_library() if not libraries: - console.print() - console.print( + get_console().print() + get_console().print( Panel( "[yellow]No libraries configured.[/yellow]\n\n" "[cyan]Get Started:[/cyan]\n" @@ -80,14 +79,14 @@ def list_config( if lib_type in types: types[lib_type].append(lib) - console.print() + get_console().print() # Display GitHub Libraries if library_type in ["all", "github"] and types["github"]: - console.print( + get_console().print( "\n[bold cyan]GitHub Libraries[/bold cyan] [dim](symbols, footprints, templates)[/dim]" ) - console.print() + get_console().print() if verbose: # Verbose mode: Individual panels for each library @@ -144,14 +143,14 @@ def list_config( ) title = f"[bold cyan]{name}[/bold cyan] [{status}]" - console.print( + get_console().print( Panel( content, title=title, border_style="cyan" if path == current_library else "dim", ) ) - console.print() + get_console().print() else: # Compact mode: Simple table table = Table( @@ -174,15 +173,15 @@ def list_config( table.add_row(f"[bold]{name}[/bold]", status, path) - console.print(table) - console.print() + get_console().print(table) + get_console().print() # Display Cloud Libraries if library_type in ["all", "cloud"] and types["cloud"]: - console.print( + get_console().print( "\n[bold cyan]Cloud Libraries[/bold cyan] [dim](3D models)[/dim]" ) - console.print() + get_console().print() if verbose: # Verbose mode: Individual panels for each library @@ -238,14 +237,14 @@ def list_config( ) title = f"[bold cyan]{name}[/bold cyan] [{status}]" - console.print( + get_console().print( Panel( content, title=title, border_style="cyan" if path == current_library else "dim", ) ) - console.print() + get_console().print() else: # Compact mode: Simple table table = Table( @@ -268,8 +267,8 @@ def list_config( table.add_row(f"[bold]{name}[/bold]", status, path) - console.print(table) - console.print() + get_console().print(table) + get_console().print() # Add summary panel total_libs = len(libraries) @@ -281,7 +280,7 @@ def list_config( f"[cyan]GitHub:[/cyan] {github_count} [blue]Cloud:[/blue] {cloud_count}" ) - console.print( + get_console().print( Panel( summary_content, title="[bold cyan]Summary[/bold cyan]", @@ -292,7 +291,7 @@ def list_config( # Print helpful message if no libraries match the filter if library_type == "github" and not types["github"]: - console.print( + get_console().print( Panel( "[yellow]No GitHub libraries configured.[/yellow]\n\n" "[cyan]Get Started:[/cyan]\n" @@ -302,7 +301,7 @@ def list_config( ) ) elif library_type == "cloud" and not types["cloud"]: - console.print( + get_console().print( Panel( "[yellow]No cloud libraries configured.[/yellow]\n\n" "[cyan]Get Started:[/cyan]\n" @@ -313,7 +312,7 @@ def list_config( ) except Exception as e: - console.print(f"[red]Error listing configurations: {e}[/red]") + get_console().print(f"[red]Error listing configurations: {e}[/red]") sys.exit(1) @@ -362,11 +361,11 @@ def set_default( libraries = config.get_libraries(library_type) if not libraries: - console.print(f"No {library_type} libraries configured.") + get_console().print(f"No {library_type} libraries configured.") if library_type == "github": - console.print("Use 'kilm init' to initialize a GitHub library.") + get_console().print("Use 'kilm init' to initialize a GitHub library.") else: - console.print( + get_console().print( "Use 'kilm add-3d' to add a cloud-based 3D model library." ) sys.exit(1) @@ -376,7 +375,7 @@ def set_default( # If library name not provided, prompt for selection if not library_name: - console.print(f"\nAvailable {library_type} libraries:") + get_console().print(f"\nAvailable {library_type} libraries:") # Show numbered list of libraries for i, lib in enumerate(libraries): @@ -388,7 +387,7 @@ def set_default( if path == current_library: current_marker = " (current)" - console.print(f"{i + 1}. {name}{current_marker}") + get_console().print(f"{i + 1}. {name}{current_marker}") # Get selection while True: @@ -402,11 +401,11 @@ def set_default( library_path = selected_lib.get("path") break else: - console.print( + get_console().print( f"Please enter a number between 1 and {len(libraries)}" ) except ValueError: - console.print("Please enter a valid number") + get_console().print("Please enter a valid number") else: # Find the library by name library_path = None @@ -416,24 +415,26 @@ def set_default( break if not library_path: - console.print( + get_console().print( f"No {library_type} library named '{library_name}' found." ) - console.print("Use 'kilm config list' to see available libraries.") + get_console().print( + "Use 'kilm config list' to see available libraries." + ) sys.exit(1) # Set as current library if library_path is None: - console.print( + get_console().print( f"[red]Error: Could not find path for library '{library_name}'[/red]" ) 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}") + get_console().print(f"Set {library_type} library '{library_name}' as default.") + get_console().print(f"Path: {library_path}") except Exception as e: - console.print(f"[red]Error setting default library: {e}[/red]") + get_console().print(f"[red]Error setting default library: {e}[/red]") sys.exit(1) @@ -488,12 +489,12 @@ def remove( if not matching_libraries: if library_type == "all": - console.print(f"No library named '{library_name}' found.") + get_console().print(f"No library named '{library_name}' found.") else: - console.print( + get_console().print( f"No {library_type} library named '{library_name}' found." ) - console.print("Use 'kilm config list' to see available libraries.") + get_console().print("Use 'kilm config list' to see available libraries.") sys.exit(1) # Confirm removal @@ -501,16 +502,16 @@ def remove( for lib in matching_libraries: lib_type = lib.get("type", "unknown") lib_path = lib.get("path", "unknown") - console.print( + get_console().print( f"Will remove {lib_type} library '{library_name}' from configuration." ) - console.print(f"Path: {lib_path}") + get_console().print(f"Path: {lib_path}") if lib_path == current_library: - console.print("Warning: This is the current default library.") + get_console().print("Warning: This is the current default library.") if not typer.confirm("Continue?"): - console.print("Operation cancelled.") + get_console().print("Operation cancelled.") return # Remove libraries @@ -523,21 +524,23 @@ def remove( if removed_count > 0: if removed_count == 1: - console.print(f"Removed library '{library_name}' from configuration.") + get_console().print( + f"Removed library '{library_name}' from configuration." + ) else: - console.print( + get_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( + get_console().print( "Note: Default library was changed as the previous default was removed." ) else: - console.print("No libraries were removed.") + get_console().print("No libraries were removed.") except Exception as e: - console.print(f"[red]Error removing library: {e}[/red]") + get_console().print(f"[red]Error removing library: {e}[/red]") sys.exit(1) diff --git a/kicad_lib_manager/commands/import_zip/command.py b/kicad_lib_manager/commands/import_zip/command.py index 510cb38..ea22835 100644 --- a/kicad_lib_manager/commands/import_zip/command.py +++ b/kicad_lib_manager/commands/import_zip/command.py @@ -10,7 +10,8 @@ from typing import Annotated import typer -from rich.console import Console + +from kicad_lib_manager.utils.console import get_console from ...services.config_service import Config from .errors import ImportFailure @@ -18,8 +19,6 @@ from .kicad_cli import Upgrader, detect_kicad_cli from .target import build_target -console = Console() - def import_zip( zip_files: Annotated[ @@ -78,7 +77,7 @@ def import_zip( Config(), library, symbol_lib, footprint_lib, upgrader, 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] = [] @@ -91,20 +90,20 @@ def import_zip( _print_summary(totals, 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.""" 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 +113,32 @@ 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) + 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)}") + 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( + 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/importer.py b/kicad_lib_manager/commands/import_zip/importer.py index a1b6da5..e20f82e 100644 --- a/kicad_lib_manager/commands/import_zip/importer.py +++ b/kicad_lib_manager/commands/import_zip/importer.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from pathlib import Path -from rich.console import Console +from kicad_lib_manager.utils.console import get_console from ...utils.backup import create_backup from ...utils.file_ops import write_atomically @@ -16,8 +16,6 @@ from .kicad_cli import Upgrader from .symbols import SymbolMerge, merge_symbols -console = Console() - @dataclass(frozen=True) class ModelRef: @@ -126,7 +124,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() @@ -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..8c2b394 100644 --- a/kicad_lib_manager/commands/import_zip/kicad_cli.py +++ b/kicad_lib_manager/commands/import_zip/kicad_cli.py @@ -9,12 +9,10 @@ from dataclasses import dataclass from pathlib import Path -from rich.console import Console +from kicad_lib_manager.utils.console import get_console from .formats import format_version -console = Console() - _KICAD_CLI_CANDIDATES: tuple[Path, ...] = ( Path.home() / "AppImages" / "kicad.appimage", Path("/usr/bin/kicad-cli"), @@ -119,12 +117,12 @@ def _run(kicad_cli: Path, label: str, *args: str) -> bool: cmd, capture_output=True, text=True, check=True, timeout=_TIMEOUT_SECONDS ) except subprocess.TimeoutExpired: - console.print( + 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( + get_console().print( f"[yellow] warn: kicad-cli {args[0]} upgrade failed for {label}: {exc.stderr.strip()}[/yellow]" ) return False @@ -136,7 +134,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/target.py b/kicad_lib_manager/commands/import_zip/target.py index 4c5a78b..33ead5a 100644 --- a/kicad_lib_manager/commands/import_zip/target.py +++ b/kicad_lib_manager/commands/import_zip/target.py @@ -3,7 +3,8 @@ from pathlib import Path import typer -from rich.console import Console + +from kicad_lib_manager.utils.console import get_console from ...services.config_service import Config, LibraryDict from ...utils.metadata import read_cloud_metadata, read_github_metadata @@ -11,8 +12,6 @@ from .importer import ImportTarget, ModelRef from .kicad_cli import Upgrader -console = Console() - def build_target( config: Config, @@ -34,13 +33,13 @@ def build_target( 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]" @@ -65,12 +64,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 +93,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 +110,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,11 +127,13 @@ 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( + get_console().print( f"[red]Several {kind} libraries found, choose one with {option}: {available}[/red]" ) raise typer.Exit(1) @@ -173,7 +178,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]" ) diff --git a/kicad_lib_manager/commands/init/command.py b/kicad_lib_manager/commands/init/command.py index 1e93fc5..154acb1 100644 --- a/kicad_lib_manager/commands/init/command.py +++ b/kicad_lib_manager/commands/init/command.py @@ -7,15 +7,14 @@ from typing import Annotated import typer -from rich.console import Console from rich.panel import Panel from rich.prompt import Confirm +from kicad_lib_manager.utils.console import get_console + from ...services.config_service import Config from ...services.library_service import LibraryService -console = Console() - def init( name: Annotated[ @@ -79,7 +78,7 @@ def init( """ current_dir = Path.cwd().resolve() - console.print( + get_console().print( Panel( f"[bold blue]Initializing KiCad library[/bold blue]\n" f"[cyan]Location:[/cyan] {current_dir}", @@ -117,7 +116,7 @@ def init( created_folders.append(folder_type) except Exception as e: - console.print(f"[red]Error initializing library: {e}[/red]") + get_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 @@ -142,11 +141,11 @@ def init( """ with library_descriptions_file.open("w", encoding="utf-8") as f: f.write(template_content) - console.print( + get_console().print( "[green]Created library_descriptions.yaml template file.[/green]" ) except Exception as e: - console.print( + get_console().print( f"[yellow]Warning: Could not create library_descriptions.yaml file: {e}[/yellow]" ) @@ -154,21 +153,21 @@ def init( # Report on folder status if existing_folders: - console.print( + get_console().print( f"[blue]Found existing folders:[/blue] {', '.join(existing_folders)}" ) if created_folders: - console.print( + get_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( + get_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]") + get_console().print("[yellow]Initialization cancelled.[/yellow]") raise typer.Exit(0) # Update the configuration @@ -199,7 +198,7 @@ def init( "[dim]KiLM will use this library for all commands by default.[/dim]" ) - console.print( + get_console().print( Panel( success_content, title="✅ Initialization Complete", @@ -208,14 +207,14 @@ def init( ) # Add a hint for adding 3D models - console.print("\n[bold]Next Steps:[/bold]") - console.print( + get_console().print("\n[bold]Next Steps:[/bold]") + get_console().print( "To add a 3D models directory (typically stored in the cloud), use:" ) - console.print( + get_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]") + get_console().print(f"[red]Error initializing library: {e}[/red]") raise typer.Exit(1) from e diff --git a/kicad_lib_manager/commands/list_libraries/command.py b/kicad_lib_manager/commands/list_libraries/command.py index 4421337..8de7053 100644 --- a/kicad_lib_manager/commands/list_libraries/command.py +++ b/kicad_lib_manager/commands/list_libraries/command.py @@ -6,14 +6,13 @@ from typing import Annotated import typer -from rich.console import Console from rich.table import Table +from kicad_lib_manager.utils.console import get_console + from ...services.library_service import LibraryService from ...utils.env_vars import expand_user_path, find_environment_variables -console = Console() - def list_cmd( kicad_lib_dir: Annotated[ @@ -35,7 +34,9 @@ def list_cmd( 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]") + get_console().print( + "[red]Error: KICAD_USER_LIB not set and not provided[/red]" + ) raise typer.Exit(1) # Expand user home directory if needed @@ -45,7 +46,9 @@ def list_cmd( 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") + get_console().print( + f"[blue]Scanning library directory:[/blue] {kicad_lib_dir}\n" + ) # Display symbol libraries in a table if symbols: @@ -60,11 +63,11 @@ def list_cmd( for symbol in sorted(symbols): symbol_table.add_row(symbol, ".kicad_sym") - console.print(symbol_table) + get_console().print(symbol_table) else: - console.print("[yellow]No symbol libraries found[/yellow]") + get_console().print("[yellow]No symbol libraries found[/yellow]") - console.print() # Empty line for spacing + get_console().print() # Empty line for spacing # Display footprint libraries in a table if footprints: @@ -79,23 +82,23 @@ def list_cmd( for footprint in sorted(footprints): footprint_table.add_row(footprint, ".pretty") - console.print(footprint_table) + get_console().print(footprint_table) else: - console.print("[yellow]No footprint libraries found[/yellow]") + get_console().print("[yellow]No footprint libraries found[/yellow]") # Summary information total_libs = len(symbols) + len(footprints) if total_libs > 0: - console.print( + get_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( + get_console().print( "[yellow]No libraries found in the specified directory[/yellow]" ) except Exception as e: - console.print(f"[red]Error listing libraries: {e}[/red]") + get_console().print(f"[red]Error listing libraries: {e}[/red]") raise typer.Exit(1) from e diff --git a/kicad_lib_manager/commands/pin/command.py b/kicad_lib_manager/commands/pin/command.py index 8ffea1c..51e4716 100644 --- a/kicad_lib_manager/commands/pin/command.py +++ b/kicad_lib_manager/commands/pin/command.py @@ -6,10 +6,11 @@ from typing import Annotated import typer -from rich.console import Console from rich.panel import Panel from rich.table import Table +from kicad_lib_manager.utils.console import get_console + from ...services.kicad_service import KiCadService from ...services.library_service import LibraryService from ...utils.env_vars import ( @@ -18,8 +19,6 @@ update_pinned_libraries, ) -console = Console() - def pin( kicad_lib_dir: Annotated[ @@ -92,14 +91,18 @@ def pin( 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]") + get_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) if verbose: - console.print(f"[blue]Using KiCad library directory:[/blue] {kicad_lib_dir}") + get_console().print( + f"[blue]Using KiCad library directory:[/blue] {kicad_lib_dir}" + ) # Initialize services library_service = LibraryService() @@ -109,9 +112,11 @@ def pin( try: kicad_config = kicad_service.find_kicad_config_dir() if verbose: - console.print(f"[blue]Found KiCad configuration at:[/blue] {kicad_config}") + get_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]") + get_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 @@ -123,11 +128,11 @@ def pin( symbols = list(symbol_libs) footprints = list(footprint_libs) if verbose: - console.print( + get_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]") + get_console().print(f"[red]Error listing libraries: {e}[/red]") raise typer.Exit(1) from e # Ensure we have lists (Typer should already provide lists) @@ -146,18 +151,18 @@ def pin( # Check symbols for symbol in symbols: if symbol not in available_symbols: - console.print( + get_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( + get_console().print( f"[yellow]Warning: Footprint library '{footprint}' not found[/yellow]" ) except Exception as e: - console.print(f"[yellow]Error validating libraries: {e}[/yellow]") + get_console().print(f"[yellow]Error validating libraries: {e}[/yellow]") # Continue anyway, in case the libraries are configured but not in the directory try: @@ -171,12 +176,12 @@ def pin( if changes_needed: if dry_run: - console.print( + get_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( + get_console().print( Panel( f"{success_msg}\n\n" f"[blue]• Created backup of kicad_common.json[/blue]\n" @@ -186,7 +191,7 @@ def pin( ) ) else: - console.print( + get_console().print( "[blue]No changes needed, libraries already pinned in KiCad[/blue]" ) @@ -196,14 +201,14 @@ def pin( table.add_column("Library", style="cyan") for symbol in sorted(symbols): table.add_row(symbol) - console.print(table) + get_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) + get_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 diff --git a/kicad_lib_manager/commands/setup/command.py b/kicad_lib_manager/commands/setup/command.py index 707de09..17b8242 100644 --- a/kicad_lib_manager/commands/setup/command.py +++ b/kicad_lib_manager/commands/setup/command.py @@ -6,10 +6,11 @@ from pathlib import Path import typer -from rich.console import Console from rich.panel import Panel from rich.table import Table +from kicad_lib_manager.utils.console import get_console + from ...services.config_service import Config, LibraryDict from ...services.library_service import LibraryService from ...utils.backup import create_backup @@ -22,8 +23,6 @@ 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, @@ -136,14 +135,14 @@ def setup( if kicad_lib_dir: cmd_line_lib_paths["symbols"] = kicad_lib_dir if verbose: - console.print( + get_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( + get_console().print( f"3D model library specified on command line: [blue]{kicad_3d_dir}[/blue]" ) @@ -152,13 +151,13 @@ def setup( 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}") + get_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}") + get_console().print(f"Requested symbol libraries: {symbol_lib_names}") # Check Config file for library paths config_lib_paths: dict[str, str] = {} @@ -172,18 +171,18 @@ def setup( # Display configuration file location if verbose if verbose: config_file = config_obj._get_config_file() - console.print(f"Looking for configuration in: {config_file}") + get_console().print(f"Looking for configuration in: {config_file}") if config_file.exists(): - console.print("Configuration file exists") + get_console().print("Configuration file exists") else: - console.print("Configuration file does not exist") + get_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( + get_console().print( f"Found {len(all_symbol_libs)} symbol libraries and {len(all_3d_libs)} 3D model libraries in config" ) @@ -228,7 +227,7 @@ def setup( # Print what we're setting up if config_symbol_libs: - console.print() + get_console().print() table = Table( title="[bold cyan]Setting up symbol libraries[/bold cyan]", show_header=True, @@ -270,10 +269,10 @@ def setup( table.add_row(f"[bold]{lib_name}[/bold]", lib_path, env_var_display) - console.print(table) + get_console().print(table) if config_3d_libs: - console.print() + get_console().print() table = Table( title="[bold cyan]Setting up 3D model libraries[/bold cyan]", show_header=True, @@ -312,15 +311,15 @@ def setup( table.add_row(f"[bold]{lib_name}[/bold]", lib_path, env_var_display) - console.print(table) + get_console().print(table) except Exception as e: # If there's any issue with config, continue with environment variables if verbose: - console.print(f"Error reading from config: {e}") + get_console().print(f"Error reading from config: {e}") import traceback - console.print(traceback.format_exc()) + get_console().print(traceback.format_exc()) # Fall back to environment variables if still not found env_lib_paths = {} @@ -329,12 +328,14 @@ def setup( if env_var: kicad_lib_dir = env_var env_lib_paths["KICAD_USER_LIB"] = env_var - console.print( + get_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( + get_console().print( + "[red]Error: KICAD_USER_LIB not set and not provided[/red]" + ) + get_console().print( "[yellow]Consider initializing a library with 'kilm init' first.[/yellow]" ) raise typer.Exit(1) @@ -344,34 +345,34 @@ def setup( if env_var: kicad_3d_dir = env_var env_lib_paths["KICAD_3D_LIB"] = env_var - console.print( + get_console().print( f"[green]Using 3D model library from environment variable:[/green] [blue]{kicad_3d_dir}[/blue]" ) else: - console.print( + get_console().print( "[yellow]Warning: KICAD_3D_LIB not set, 3D models might not work correctly[/yellow]" ) - console.print( + get_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:") + get_console().print("\nSummary of library sources:") if cmd_line_lib_paths: - console.print(" From command line:") + get_console().print(" From command line:") for lib_type, path in cmd_line_lib_paths.items(): - console.print(f" - {lib_type}: {path}") + get_console().print(f" - {lib_type}: {path}") if config_lib_paths: - console.print(" From config file:") + get_console().print(" From config file:") for lib_type, path in config_lib_paths.items(): - console.print(f" - {lib_type}: {path}") + get_console().print(f" - {lib_type}: {path}") if env_lib_paths: - console.print(" From environment variables:") + get_console().print(" From environment variables:") for lib_type, path in env_lib_paths.items(): - console.print(f" - {lib_type}: {path}") + get_console().print(f" - {lib_type}: {path}") # Expand user home directory if needed kicad_lib_dir = expand_user_path(kicad_lib_dir) @@ -389,8 +390,8 @@ def setup( else: config_content += "[yellow]3D models directory: Not configured[/yellow]" - console.print() - console.print( + get_console().print() + get_console().print( Panel( config_content, title="[bold cyan]Configuration Summary[/bold cyan]", @@ -401,7 +402,7 @@ def setup( # Find KiCad configuration try: kicad_config = LibraryService.find_kicad_config() - console.print( + get_console().print( f"[green]Found KiCad configuration at:[/green] [blue]{kicad_config}[/blue]" ) @@ -409,15 +410,15 @@ def setup( uri_changes = fix_invalid_uris(kicad_config, True, max_backups, dry_run) if uri_changes: if dry_run: - console.print( + get_console().print( "[yellow]Would fix invalid library URIs in KiCad configuration[/yellow]" ) else: - console.print( + get_console().print( "[green]Fixed invalid library URIs in KiCad configuration[/green]" ) except Exception as e: - console.print(f"[red]Error finding KiCad configuration: {e}[/red]") + get_console().print(f"[red]Error finding KiCad configuration: {e}[/red]") raise typer.Exit(1) from e # Prepare environment variables dictionary @@ -445,27 +446,29 @@ def setup( ) if env_changes_needed: if dry_run: - console.print( + get_console().print( "[yellow]Would update environment variables in KiCad configuration[/yellow]" ) else: - console.print( + get_console().print( "[green]Updated environment variables in KiCad configuration[/green]" ) - console.print("[blue]Created backup of kicad_common.json[/blue]") + get_console().print("[blue]Created backup of kicad_common.json[/blue]") # Show all environment variables that were set - console.print( + get_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]") + get_console().print( + f" [cyan]{var_name}[/cyan] = [blue]{value}[/blue]" + ) else: - console.print( + get_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]") + get_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 @@ -496,23 +499,27 @@ def setup( if sym_table.exists(): create_backup(sym_table, max_backups) - console.print("[blue]Created backup of symbol library table[/blue]") + get_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]") + get_console().print( + "[blue]Created backup of footprint library table[/blue]" + ) if added_libraries: if dry_run: - console.print( + get_console().print( f"[yellow]Would add {len(added_libraries)} libraries to KiCad configuration[/yellow]" ) else: - console.print( + get_console().print( f"[green]Added {len(added_libraries)} libraries to KiCad configuration[/green]" ) else: - console.print("[blue]No new libraries to add[/blue]") + get_console().print("[blue]No new libraries to add[/blue]") # Pin libraries if requested pinned_changes_needed = False @@ -528,11 +535,11 @@ def setup( footprint_libs = existing_footprints if verbose: - console.print( + get_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]") + get_console().print(f"[red]Error listing libraries to pin: {e}[/red]") try: pinned_changes_needed = update_pinned_libraries( @@ -544,33 +551,37 @@ def setup( if pinned_changes_needed: if dry_run: - console.print( + get_console().print( f"[yellow]Would pin {len(symbol_libs)} symbol and {len(footprint_libs)} footprint libraries in KiCad[/yellow]" ) else: - console.print( + get_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]") + get_console().print( + "[blue]All libraries already pinned in KiCad[/blue]" + ) except Exception as e: - console.print(f"[red]Error pinning libraries: {e}[/red]") + get_console().print(f"[red]Error pinning libraries: {e}[/red]") 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]") + get_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]") + get_console().print("[yellow]Dry run: No changes were made[/yellow]") except Exception as e: - console.print(f"[red]Error adding libraries: {e}[/red]") + get_console().print(f"[red]Error adding libraries: {e}[/red]") if verbose: import traceback - console.print(traceback.format_exc()) + get_console().print(traceback.format_exc()) raise typer.Exit(1) from None if not dry_run and (changes_needed or env_changes_needed or pinned_changes_needed): - console.print() - console.print( + 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]", diff --git a/kicad_lib_manager/commands/status/command.py b/kicad_lib_manager/commands/status/command.py index 61860d6..b067adc 100644 --- a/kicad_lib_manager/commands/status/command.py +++ b/kicad_lib_manager/commands/status/command.py @@ -8,17 +8,16 @@ 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 kicad_lib_manager.utils.console import get_console + 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() - def status( verbose: Annotated[ @@ -38,10 +37,12 @@ def status( # Show KILM configuration first _show_kilm_configuration() - console.print("\n[bold cyan]KiCad Configuration[/bold cyan]") + get_console().print("\n[bold cyan]KiCad Configuration[/bold cyan]") kicad_config = kicad_service.find_kicad_config_dir() - console.print(f"KiCad configuration directory: [blue]{kicad_config}[/blue]") + get_console().print( + f"KiCad configuration directory: [blue]{kicad_config}[/blue]" + ) # Check environment variables in KiCad common _show_kicad_environment_variables(kicad_config) @@ -54,7 +55,7 @@ def status( _show_configured_libraries(kicad_config, kicad_service) except Exception as e: - console.print(f"[red]Error getting KiCad configuration: {e}[/red]") + get_console().print(f"[red]Error getting KiCad configuration: {e}[/red]") raise typer.Exit(1) from e @@ -63,7 +64,7 @@ def _show_kilm_configuration() -> None: try: config_file = Path.home() / ".config" / CONFIG_DIR_NAME / CONFIG_FILE_NAME if config_file.exists(): - console.print("[bold cyan]KILM Configuration[/bold cyan]") + get_console().print("[bold cyan]KILM Configuration[/bold cyan]") try: with config_file.open() as f: config_data = yaml.safe_load(f) @@ -72,24 +73,24 @@ def _show_kilm_configuration() -> None: _show_configured_libraries_table(config_data) _show_kilm_settings(config_data) else: - console.print( + get_console().print( "[yellow]Configuration file is empty or invalid[/yellow]" ) except Exception as e: - console.print(f"[red]Error reading configuration: {e}[/red]") + get_console().print(f"[red]Error reading configuration: {e}[/red]") else: - console.print( + get_console().print( "[yellow]No KILM configuration file found. Run 'kilm init' to create one.[/yellow]" ) except Exception as e: - console.print(f"[red]Error reading KILM configuration: {e}[/red]") + get_console().print(f"[red]Error reading KILM configuration: {e}[/red]") 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]") + get_console().print("[yellow] No libraries configured[/yellow]") return # Create tables for different library types @@ -120,7 +121,7 @@ def _show_configured_libraries_table(config_data: dict) -> None: table.add_row(name, path, is_current, has_metadata) - console.print(table) + get_console().print(table) if cloud_libs: table = Table(title="Cloud Libraries (3D models)", show_header=True) @@ -142,7 +143,7 @@ def _show_configured_libraries_table(config_data: dict) -> None: table.add_row(name, path, is_current, has_metadata) - console.print(table) + get_console().print(table) def _show_kilm_settings(config_data: dict) -> None: @@ -165,21 +166,21 @@ def _show_kilm_settings(config_data: dict) -> 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" if not kicad_common.exists(): - console.print("[yellow]No kicad_common.json found[/yellow]") + get_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]") + get_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"] @@ -191,13 +192,13 @@ def _show_kicad_environment_variables(kicad_config: Path) -> None: for key, value in env_vars.items(): table.add_row(key, str(value)) - console.print(table) + get_console().print(table) else: - console.print("[yellow] No environment variables set[/yellow]") + get_console().print("[yellow] No environment variables set[/yellow]") else: - console.print("[yellow] No environment variables found[/yellow]") + get_console().print("[yellow] No environment variables found[/yellow]") except Exception as e: - console.print(f"[red]Error reading KiCad common configuration: {e}[/red]") + get_console().print(f"[red]Error reading KiCad common configuration: {e}[/red]") def _check_pinned_libraries(kicad_config: Path, kicad_service: KiCadService) -> None: @@ -210,7 +211,7 @@ def _check_pinned_libraries(kicad_config: Path, kicad_service: KiCadService) -> common_config = json.load(f) found_pinned = False - console.print("\n[bold]Pinned Libraries in KiCad:[/bold]") + get_console().print("\n[bold]Pinned Libraries in KiCad:[/bold]") # Check for pinned symbol libraries if ( @@ -220,9 +221,9 @@ def _check_pinned_libraries(kicad_config: Path, kicad_service: KiCadService) -> sym_libs = common_config["session"]["pinned_symbol_libs"] if sym_libs: found_pinned = True - console.print("[cyan]Symbol Libraries:[/cyan]") + get_console().print("[cyan]Symbol Libraries:[/cyan]") for lib in sym_libs: - console.print(f" • {lib}") + get_console().print(f" • {lib}") # Check for pinned footprint libraries if ( @@ -232,18 +233,18 @@ def _check_pinned_libraries(kicad_config: Path, kicad_service: KiCadService) -> fp_libs = common_config["session"]["pinned_fp_libs"] if fp_libs: found_pinned = True - console.print("[cyan]Footprint Libraries:[/cyan]") + get_console().print("[cyan]Footprint Libraries:[/cyan]") for lib in fp_libs: - console.print(f" • {lib}") + get_console().print(f" • {lib}") if not found_pinned: - console.print( + get_console().print( "[yellow] No pinned libraries found in kicad_common.json[/yellow]" ) return except Exception as e: - console.print( + get_console().print( f"[red]Error reading pinned libraries from kicad_common.json: {e}[/red]" ) @@ -254,31 +255,33 @@ def _check_pinned_libraries(kicad_config: Path, kicad_service: KiCadService) -> with pinned_libs.open() as f: pinned_config = json.load(f) - console.print("\n[bold]Pinned Libraries in KiCad (legacy format):[/bold]") + get_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]") + get_console().print("[cyan]Symbol Libraries:[/cyan]") for lib in sym_libs: - console.print(f" • {lib}") + get_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]") + get_console().print("[cyan]Footprint Libraries:[/cyan]") for lib in fp_libs: - console.print(f" • {lib}") + get_console().print(f" • {lib}") if not found_pinned: - console.print("[yellow] No pinned libraries found[/yellow]") + get_console().print("[yellow] No pinned libraries found[/yellow]") except Exception as e: - console.print(f"[red]Error reading pinned libraries: {e}[/red]") + get_console().print(f"[red]Error reading pinned libraries: {e}[/red]") else: - console.print("\n[yellow]No pinned libraries file found[/yellow]") + get_console().print("\n[yellow]No pinned libraries file found[/yellow]") def _show_configured_libraries(kicad_config: Path, kicad_service: KiCadService) -> None: @@ -286,7 +289,7 @@ def _show_configured_libraries(kicad_config: Path, kicad_service: KiCadService) try: sym_libs, fp_libs = kicad_service.get_configured_libraries(kicad_config) - console.print("\n[bold]Configured Symbol Libraries:[/bold]") + get_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) @@ -295,11 +298,11 @@ def _show_configured_libraries(kicad_config: Path, kicad_service: KiCadService) for lib in sym_libs: table.add_row(lib.get("name", ""), lib.get("uri", "")) - console.print(table) + get_console().print(table) else: - console.print("[yellow] No symbol libraries configured[/yellow]") + get_console().print("[yellow] No symbol libraries configured[/yellow]") - console.print("\n[bold]Configured Footprint Libraries:[/bold]") + get_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) @@ -308,8 +311,8 @@ def _show_configured_libraries(kicad_config: Path, kicad_service: KiCadService) for lib in fp_libs: table.add_row(lib.get("name", ""), lib.get("uri", "")) - console.print(table) + get_console().print(table) else: - console.print("[yellow] No footprint libraries configured[/yellow]") + get_console().print("[yellow] No footprint libraries configured[/yellow]") except Exception as e: - console.print(f"[red]Error listing configured libraries: {e}[/red]") + get_console().print(f"[red]Error listing configured libraries: {e}[/red]") diff --git a/kicad_lib_manager/commands/sync/command.py b/kicad_lib_manager/commands/sync/command.py index 878334a..df4d55d 100644 --- a/kicad_lib_manager/commands/sync/command.py +++ b/kicad_lib_manager/commands/sync/command.py @@ -8,11 +8,10 @@ from typing import Annotated import typer -from rich.console import Console -from ...services.config_service import Config +from kicad_lib_manager.utils.console import get_console -console = Console() +from ...services.config_service import Config def sync( @@ -44,12 +43,12 @@ def sync( libraries = config.get_libraries(library_type="github") if not libraries: - console.print( + get_console().print( "[yellow]No GitHub libraries configured. Use 'kilm init' to add a library.[/yellow]" ) return - console.print( + get_console().print( f"[cyan]Syncing {len(libraries)} KiCad GitHub libraries...[/cyan]" ) @@ -66,7 +65,7 @@ def sync( lib_path = lib.get("path") if not lib_path: - console.print( + get_console().print( f" [yellow]Skipping {lib_name}: No path defined[/yellow]" ) skipped_count += 1 @@ -74,7 +73,7 @@ def sync( lib_path = Path(lib_path) if not lib_path.exists(): - console.print( + get_console().print( f" [yellow]Skipping {lib_name}: Path does not exist: {lib_path}[/yellow]" ) skipped_count += 1 @@ -82,17 +81,17 @@ def sync( git_dir = lib_path / ".git" if not git_dir.exists() or not git_dir.is_dir(): - console.print( + get_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]") + get_console().print(f" [cyan]Syncing {lib_name} at {lib_path}...[/cyan]") if dry_run: - console.print( + get_console().print( f" [blue]Dry run: would execute 'git pull' in {lib_path}[/blue]" ) updated_count += 1 @@ -113,17 +112,17 @@ def sync( is_updated = "Already up to date" not in output if verbose: - console.print(f" [green]Success: {output}[/green]") + get_console().print(f" [green]Success: {output}[/green]") # Also show the short status for consistency if is_updated: - console.print(" [green]Updated[/green]") + get_console().print(" [green]Updated[/green]") else: - console.print(" [blue]Up to date[/blue]") + get_console().print(" [blue]Up to date[/blue]") else: if is_updated: - console.print(" [green]Updated[/green]") + get_console().print(" [green]Updated[/green]") else: - console.print(" [blue]Up to date[/blue]") + get_console().print(" [blue]Up to date[/blue]") # Update counters regardless of verbose flag if is_updated: @@ -139,32 +138,36 @@ def sync( (lib_name, lib_path, changes_require_setup) ) if verbose: - console.print( + get_console().print( f" [yellow]Detected new library content: {', '.join(changes_require_setup)}[/yellow]" ) else: - console.print(f" [red]Failed: {result.stderr.strip()}[/red]") + get_console().print( + f" [red]Failed: {result.stderr.strip()}[/red]" + ) failed_count += 1 except Exception as e: - console.print(f" [red]Error: {str(e)}[/red]") + get_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") + get_console().print("\n[bold cyan]Sync Summary:[/bold cyan]") + get_console().print(f" [green]{updated_count}[/green] libraries synced") + get_console().print(f" [blue]{up_to_date_count}[/blue] libraries up to date") + get_console().print(f" [yellow]{skipped_count}[/yellow] libraries skipped") + get_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]") + get_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)}") + get_console().print( + f" - [cyan]{lib_name}[/cyan]: {', '.join(changes)}" + ) if auto_setup: - console.print( + get_console().print( "\n[green]Running 'kilm setup' to configure new libraries...[/green]" ) # Import at runtime to avoid circular imports @@ -173,26 +176,26 @@ def sync( setup_cmd() except ImportError: - console.print( + get_console().print( "[red]Error: Could not import setup command. Please run 'kilm setup' manually.[/red]" ) else: - console.print( + get_console().print( "\n[yellow]You should run 'kilm setup' to configure the new libraries in KiCad.[/yellow]" ) - console.print( + get_console().print( "[blue]Run 'kilm sync --auto-setup' next time to automatically run setup after sync.[/blue]" ) else: - console.print( + get_console().print( "\n[blue]No new libraries detected that would require running 'kilm setup'.[/blue]" ) - console.print( + get_console().print( "[blue]Use 'kilm status' to check your current configuration.[/blue]" ) except Exception as e: - console.print(f"[red]Error: {e}[/red]") + get_console().print(f"[red]Error: {e}[/red]") raise typer.Exit(1) from e diff --git a/kicad_lib_manager/commands/template/command.py b/kicad_lib_manager/commands/template/command.py index 660aac2..b270aea 100644 --- a/kicad_lib_manager/commands/template/command.py +++ b/kicad_lib_manager/commands/template/command.py @@ -16,10 +16,11 @@ import questionary import typer import yaml -from rich.console import Console from rich.panel import Panel from rich.table import Table +from kicad_lib_manager.utils.console import get_console + from ...services.config_service import Config from ...utils.template import ( HOOKS_DIR, @@ -37,9 +38,6 @@ render_template_string, ) -console = Console() -err_console = Console(stderr=True) - def main_callback() -> None: """Manage KiCad project templates. @@ -115,8 +113,8 @@ def create( 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.") + get_console().print("No templates found in any configured libraries.") + get_console().print("Use 'kilm template make' to create a template first.") return # Parse name and directory @@ -154,12 +152,12 @@ def create( break if not selected_template: - console.print(f"Template '{template}' not found.") - console.print("Available templates:") + get_console().print(f"Template '{template}' not found.") + get_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}") + get_console().print(f" {t_name} ({library}): {description}") return else: # Interactive template selection using questionary @@ -176,20 +174,20 @@ def create( ).ask() if selected_template_name is None: # Handle cancellation - console.print("Template selection cancelled.") + get_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]") + get_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( + get_console(stderr=True).print( f"Error: Template metadata for '{selected_template.get('name')}' is missing the 'path'." ) return @@ -201,12 +199,14 @@ def create( with metadata_file.open() as f: metadata = yaml.safe_load(f) if not metadata: # Handle empty metadata file - err_console.print( + get_console(stderr=True).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}") + get_console(stderr=True).print( + f"Error reading template metadata {metadata_file}: {e}" + ) return # Get template variables from metadata @@ -238,14 +238,14 @@ def create( 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')}") + get_console().print() + get_console().print(f"Using Template: {metadata.get('name', 'Unknown')}") + get_console().print(f"Description: {metadata.get('description', 'N/A')}") if metadata.get("use_case"): - console.print(f"Use case: {metadata.get('use_case')}") + get_console().print(f"Use case: {metadata.get('use_case')}") - console.print() - console.print("Template Variables:") + get_console().print() + get_console().print("Template Variables:") # Combine initial variables from args and --set-var variables.update(command_line_vars) @@ -263,7 +263,7 @@ def create( else "--set-var" ) if var_name in command_line_vars or (var_name == "project_name" and name): - console.print( + get_console().print( f" {var_name}: {variables[var_name]} (from {source}) - {description}" ) continue # Skip prompting @@ -281,14 +281,14 @@ def create( 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( + get_console(stderr=True).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( + get_console(stderr=True).print( f"Warning: Could not render default for {var_name}: {e}" ) rendered_default = default # Use original default on other errors @@ -301,7 +301,7 @@ def create( ).ask() if answer is None: # Handle Ctrl+C or cancellation - console.print("Variable input cancelled.") + get_console().print("Variable input cancelled.") return # Exit the command gracefully # Store the answer for use in subsequent default renderings @@ -339,7 +339,7 @@ def create( original_default_template, variables ) except Exception as e: - err_console.print( + get_console(stderr=True).print( f"Warning: Could not re-render default for {var_name}: {e}" ) correct_default = variables[var_name] # Keep existing value on error @@ -350,14 +350,14 @@ def create( variables[var_name] == shown_default and variables[var_name] != correct_default ): - console.print( + get_console().print( f"Updating default for '{var_name}': '{shown_default}' -> '{correct_default}'" ) variables[var_name] = correct_default # --- End Post-processing --- - console.print() - console.print( + get_console().print() + get_console().print( f"Final variables: {json.dumps(variables, indent=2)}" ) # Changed message for clarity @@ -379,7 +379,7 @@ def create( ) final_project_dir = project_dir / dir_name_rendered except Exception as e: - err_console.print( + get_console(stderr=True).print( f"Warning: Could not render default directory_name '{directory_name_template}': {e}" ) # Fallback below @@ -388,22 +388,24 @@ def create( 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}") + get_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( + get_console(stderr=True).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}") + get_console().print() + get_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.") + get_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 @@ -411,21 +413,23 @@ def create( if not typer.confirm( f"Directory '{final_project_dir}' already exists. Overwrite?" ): - console.print("Aborted.") + get_console().print("Aborted.") return else: - console.print(f"Removing existing directory: {final_project_dir}") + get_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}") + get_console(stderr=True).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( + get_console(stderr=True).print( f"Error creating parent directories for {final_project_dir}: {e}" ) return @@ -443,25 +447,33 @@ def create( old_syntax_files.append(file) if old_syntax_files: - console.print() - err_console.print("WARNING: Windows Compatibility Notice:") - err_console.print( + get_console().print() + get_console(stderr=True).print( + "WARNING: Windows Compatibility Notice:" + ) + get_console(stderr=True).print( "This template uses the old {{variable}} syntax in filenames, which may not work on Windows." ) - err_console.print( + get_console(stderr=True).print( "Consider updating the template to use the new Windows-compatible %{variable} syntax." ) - err_console.print("Files with old syntax:") + get_console(stderr=True).print("Files with old syntax:") for file in old_syntax_files[:3]: # Show first 3 files - err_console.print(f" - {file}") + get_console(stderr=True).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() + get_console(stderr=True).print( + f" ... and {len(old_syntax_files) - 3} more" + ) + get_console().print() + get_console(stderr=True).print("New syntax examples:") + get_console(stderr=True).print(" - %{project_name}.kicad_pro") + get_console(stderr=True).print( + " - %{project_name.lower}.kicad_sch" + ) + get_console(stderr=True).print( + " - %{project_name.replace(' ', '-')}.kicad_pcb" + ) + get_console().print() success = create_project_from_template( template_dir=template_dir, @@ -472,19 +484,19 @@ def create( metadata=metadata, # Pass metadata for post-hook if needed ) except Exception as e: - err_console.print(f"Error during project creation: {e}") + get_console(stderr=True).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( + get_console().print() + get_console().print( f"Project '{variables.get('project_name', name)}' created successfully in '{final_project_dir}'" ) else: - console.print() - err_console.print("Project creation failed.") + get_console().print() + get_console(stderr=True).print("Project creation failed.") def make( @@ -580,7 +592,7 @@ def make( library_paths = {lib["name"]: lib["path"] for lib in all_libraries} if not library_names: - console.print( + get_console().print( "No GitHub libraries configured. Use 'kilm init' to create one first." ) sys.exit(1) @@ -618,9 +630,9 @@ def make( # 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:") + get_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_console().print(f"{i + 1}. {lib_name} ({library_paths[lib_name]})") # Get selection while True: @@ -633,14 +645,14 @@ def make( library_path = library_paths[selected_lib] break else: - console.print( + get_console().print( f"Please enter a number between 1 and {len(library_names)}" ) except ValueError: - console.print("Please enter a valid number") + get_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}") + get_console().print(f"Template will be created in: {output_directory}") else: # Non-interactive mode - use current directory if not specified if not source_directory: @@ -655,7 +667,7 @@ def make( # Find the library to add the template to library_path = config.get_symbol_library_path() if not library_path: - console.print( + get_console().print( "No library configured. Use 'kilm init' to create one first." ) return @@ -670,27 +682,27 @@ def make( # 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.") + get_console().print(f"Template '{name}' already exists at {output_directory}") + get_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}") + get_console().print(f"Creating template '{name}' from {source_directory}") + get_console().print(f"Output directory: {output_directory}") if description: - console.print(f"Description: {description}") + get_console().print(f"Description: {description}") if use_case: - console.print(f"Use case: {use_case}") + get_console().print(f"Use case: {use_case}") if exclude: - console.print("Additional exclusions:") + get_console().print("Additional exclusions:") for pattern in exclude: - console.print(f" {pattern}") + get_console().print(f" {pattern}") if extends: - console.print(f"Extends: {extends}") + get_console().print(f"Extends: {extends}") # Parse variables from command line variable_dict: dict[str, TemplateVariable] = {} @@ -703,19 +715,19 @@ def make( } if variable_dict: - console.print("\nTemplate variables:") + get_console().print("\nTemplate variables:") for key, value in variable_dict.items(): - console.print(f" {key}: {value['default']} - {value['description']}") + get_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:") + get_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}") + get_console().print(f" {var_name}: {value_str}") # Ask if the user wants to use this variable if typer.confirm( @@ -816,16 +828,18 @@ def make( included_files.sort() excluded_files.sort() - console.print("\nFiles that will be included in the template:") + get_console().print("\nFiles that will be included in the template:") for file in included_files: - console.print(f" + {file}") + get_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:") + get_console().print( + "\nMarkdown files that will be converted to Jinja templates:" + ) for file in md_files: - console.print(f" * {file}") + get_console().print(f" * {file}") # Show which KiCad project files will be templated kicad_files = [ @@ -834,7 +848,7 @@ def make( if f.lower().endswith((".kicad_pro", ".kicad_sch", ".kicad_pcb")) ] if kicad_files: - console.print("\nKiCad project files that will be templated:") + get_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"): @@ -846,15 +860,15 @@ def make( else: templated_name = file - console.print(f" * {file} → {templated_name}") + get_console().print(f" * {file} → {templated_name}") - console.print("\nFiles that will be excluded from the template:") + get_console().print("\nFiles that will be excluded from the template:") for file in excluded_files: - console.print(f" - {file}") + get_console().print(f" - {file}") # If this is a dry run, stop here if dry_run: - console.print("\nDry run complete. No changes were made.") + get_console().print("\nDry run complete. No changes were made.") return # Create the template @@ -872,36 +886,40 @@ def make( additional_excludes=exclude or None, ) - console.print(f"\nTemplate '{name}' created successfully at {output_directory}") + get_console().print( + f"\nTemplate '{name}' created successfully at {output_directory}" + ) # Add hints for next steps - console.print("\nNext steps:") - console.print( + get_console().print("\nNext steps:") + get_console().print( f"1. Edit {output_directory / TEMPLATE_METADATA if output_directory else 'N/A'} to customize template metadata" ) - console.print( + get_console().print( f"2. Customize template files in {output_directory / TEMPLATE_CONTENT_DIR if output_directory else 'N/A'}" ) - console.print( + get_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( + get_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( + get_console().print("\nFilename Templating:") + get_console().print( + "For Windows compatibility, use %{variable} syntax in filenames:" + ) + get_console().print(" - %{project_name}.kicad_pro") + get_console().print(" - %{project_name.lower}.kicad_sch") + get_console().print(" - %{project_name.replace(' ', '-')}.kicad_pcb") + get_console().print(" - %{project_name.upper.replace(' ', '_')}.md") + get_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)}") + get_console(stderr=True).print(f"Error creating template: {str(e)}") traceback.print_exc() sys.exit(1) @@ -949,8 +967,8 @@ def list_templates( all_templates = find_all_templates(config) if not all_templates: - console.print() - console.print( + get_console().print() + get_console().print( Panel( "[yellow]No templates found in any configured libraries.[/yellow]\n\n" "[cyan]💡 Get Started:[/cyan]\n" @@ -971,8 +989,8 @@ def list_templates( } if not all_templates: - console.print() - console.print( + get_console().print() + get_console().print( Panel( f"[yellow]No templates found in library '[cyan]{library}[/cyan]'.[/yellow]\n\n" "[cyan]💡 Try:[/cyan]\n" @@ -989,7 +1007,7 @@ def list_templates( if json_output: import json as json_lib - console.print(json_lib.dumps(all_templates, indent=2)) + get_console().print(json_lib.dumps(all_templates, indent=2)) return # Group templates by library for display @@ -1001,7 +1019,7 @@ def list_templates( templates_by_library[lib_name].append((name, data)) # Display templates in Rich tables organized by library - console.print() + get_console().print() for lib_name, templates in templates_by_library.items(): # Create a table for each library @@ -1045,8 +1063,8 @@ def list_templates( table.add_row(*row_data) - console.print(table) - console.print() + get_console().print(table) + get_console().print() # Add usage hint panel hint_content = ( @@ -1055,7 +1073,7 @@ def list_templates( hint_content += "[cyan]Verbose:[/cyan] kilm template list --verbose\n" hint_content += "[cyan]Filter:[/cyan] kilm template list --library " - console.print( + get_console().print( Panel( hint_content, title="[bold blue]💡 Quick Tips[/bold blue]", @@ -1069,7 +1087,7 @@ def list_templates( summary_content = f"[green]Libraries:[/green] {library_count}\n" summary_content += f"[green]Templates:[/green] {template_count}" - console.print( + get_console().print( Panel( summary_content, title="[bold cyan]📊 Summary[/bold cyan]", diff --git a/kicad_lib_manager/commands/unpin/command.py b/kicad_lib_manager/commands/unpin/command.py index 5c83038..a464e1c 100644 --- a/kicad_lib_manager/commands/unpin/command.py +++ b/kicad_lib_manager/commands/unpin/command.py @@ -5,14 +5,13 @@ import json import typer -from rich.console import Console from rich.panel import Panel +from kicad_lib_manager.utils.console import get_console + from ...services.library_service import LibraryService from ...utils.backup import create_backup -console = Console() - def unpin( symbols: list[str] | None = typer.Option( @@ -45,7 +44,7 @@ def unpin( # Enforce mutual exclusivity of --all with --symbols/--footprints if all_libraries and (symbols or footprints): - console.print( + get_console().print( "[red]Error: '--all' cannot be used with '--symbols' or '--footprints'[/red]" ) raise typer.Exit(1) @@ -54,15 +53,17 @@ def unpin( try: kicad_config = LibraryService.find_kicad_config() if verbose: - console.print(f"Found KiCad configuration at: [blue]{kicad_config}[/blue]") + get_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]") + get_console().print(f"[red]Error finding KiCad configuration: {e}[/red]") raise typer.Exit(1) from e # Get the kicad_common.json file kicad_common = kicad_config / "kicad_common.json" if not kicad_common.exists(): - console.print( + get_console().print( "[yellow]KiCad common configuration file not found, nothing to unpin[/yellow]" ) return @@ -80,32 +81,32 @@ def unpin( if verbose: if symbols: - console.print( + get_console().print( f"Found [cyan]{len(symbols)}[/cyan] pinned symbol libraries" ) if footprints: - console.print( + get_console().print( f"Found [cyan]{len(footprints)}[/cyan] pinned footprint libraries" ) else: - console.print( + get_console().print( "[yellow]No session information found in KiCad configuration, nothing to unpin[/yellow]" ) return if not symbols and not footprints: - console.print( + get_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]") + get_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: - console.print("[red]Error: No libraries specified to unpin[/red]") - console.print( + get_console().print("[red]Error: No libraries specified to unpin[/red]") + get_console().print( "[yellow]Use --symbols, --footprints, or --all to specify libraries to unpin[/yellow]" ) raise typer.Exit(1) @@ -123,7 +124,7 @@ def unpin( # Ensure session section exists if "session" not in config: - console.print( + get_console().print( "[yellow]No session information found in KiCad configuration, nothing to unpin[/yellow]" ) return @@ -160,12 +161,12 @@ def unpin( if changes_needed: if dry_run: - console.print( + get_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( + get_console().print( Panel( f"{success_msg}\n\n" f"[blue]• Created backup of kicad_common.json[/blue]\n" @@ -175,21 +176,25 @@ def unpin( ) ) else: - console.print( + get_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]") + get_console().print( + "\n[bold cyan]Unpinned Symbol Libraries:[/bold cyan]" + ) for symbol in sorted(symbols): - console.print(f" • [cyan]{symbol}[/cyan]") + get_console().print(f" • [cyan]{symbol}[/cyan]") if footprints: - console.print("\n[bold cyan]Unpinned Footprint Libraries:[/bold cyan]") + get_console().print( + "\n[bold cyan]Unpinned Footprint Libraries:[/bold cyan]" + ) for footprint in sorted(footprints): - console.print(f" • [cyan]{footprint}[/cyan]") + get_console().print(f" • [cyan]{footprint}[/cyan]") except Exception as e: - console.print(f"[red]Error unpinning libraries: {e}[/red]") + get_console().print(f"[red]Error unpinning libraries: {e}[/red]") raise typer.Exit(1) from e diff --git a/kicad_lib_manager/commands/update/check.py b/kicad_lib_manager/commands/update/check.py index ff9f496..63c1a39 100644 --- a/kicad_lib_manager/commands/update/check.py +++ b/kicad_lib_manager/commands/update/check.py @@ -4,11 +4,10 @@ from typing import Annotated import typer -from rich.console import Console -from ...services.update_service import UpdateService +from kicad_lib_manager.utils.console import get_console -console = Console() +from ...services.update_service import UpdateService def check_update_command( @@ -34,22 +33,24 @@ def check_update_command( method = update_info["method"] can_auto_update = update_info["supports_auto_update"] - 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]") + get_console().print("[yellow]Update available![/yellow]") + get_console().print(f"Current version: [blue]{current}[/blue]") + get_console().print(f"Latest version: [green]{latest}[/green]") + get_console().print(f"Installation method: [cyan]{method}[/cyan]") if can_auto_update: - console.print("\nRun [bold cyan]kilm update perform[/bold cyan] to update") + get_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]") + get_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 quiet: - console.print( + get_console().print( f"[green]✓[/green] You are using the latest version ([bold cyan]v{update_info['current_version']}[/bold cyan])" ) # Exit with code 0 for no update needed diff --git a/kicad_lib_manager/commands/update/info.py b/kicad_lib_manager/commands/update/info.py index 07a129f..cf4cb6c 100644 --- a/kicad_lib_manager/commands/update/info.py +++ b/kicad_lib_manager/commands/update/info.py @@ -4,12 +4,11 @@ from typing import Annotated import typer -from rich.console import Console from rich.panel import Panel -from ...services.update_service import UpdateService +from kicad_lib_manager.utils.console import get_console -console = Console() +from ...services.update_service import UpdateService def info_command( @@ -45,7 +44,7 @@ def info_command( ] # Display information - console.print( + get_console().print( Panel( f"""[bold]Version Information[/bold] Current version: {version_status} diff --git a/kicad_lib_manager/commands/update/perform.py b/kicad_lib_manager/commands/update/perform.py index 6a47054..c5a9db2 100644 --- a/kicad_lib_manager/commands/update/perform.py +++ b/kicad_lib_manager/commands/update/perform.py @@ -4,12 +4,11 @@ from typing import Annotated import typer -from rich.console import Console from rich.panel import Panel -from ...services.update_service import UpdateService +from kicad_lib_manager.utils.console import get_console -console = Console() +from ...services.update_service import UpdateService def perform_update_command( @@ -53,35 +52,39 @@ def perform_update_command( "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")) + get_console().print(Panel(deprecation_notice, expand=False, border_style="yellow")) version = importlib.metadata.version("kilm") update_service = UpdateService(version) if dry_run: - console.print("[yellow]Dry run mode - showing what would be done[/yellow]") - console.print() + get_console().print( + "[yellow]Dry run mode - showing what would be done[/yellow]" + ) + get_console().print() # Check if we can auto-update (skip during dry run) if not dry_run and not update_service.can_auto_update(): - console.print( + get_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]") + get_console().print(f"Installation method: [bold]{method}[/bold]") + get_console().print(f"Please run manually: [bold cyan]{update_cmd}[/bold cyan]") raise typer.Exit(1) # 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( + get_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") + get_console().print( + "\nUse [cyan]--force[/cyan] to reinstall the current version" + ) raise typer.Exit(0) current = update_info["current_version"] @@ -89,10 +92,10 @@ def perform_update_command( target = target_version or latest if not dry_run: - console.print( + get_console().print( f"Updating from [blue]{current}[/blue] to [green]{target}[/green]" ) - console.print() + get_console().print() # Perform the update success, message = update_service.perform_update( @@ -101,20 +104,22 @@ def perform_update_command( if dry_run: if success: - console.print( + get_console().print( "\n[green]✓[/green] Dry run completed - automatic update is supported" ) else: - console.print("\n[red]✗[/red] Automatic update not available") - console.print("Manual update will be required") + get_console().print("\n[red]✗[/red] Automatic update not available") + get_console().print("Manual update will be required") raise typer.Exit(0) if success: - console.print("\n[green]✓[/green] Update completed successfully!") - console.print( + get_console().print("\n[green]✓[/green] Update completed successfully!") + get_console().print( "You may need to restart your shell or run [cyan]hash -r[/cyan] to use the new version" ) else: - console.print(f"\n[red]✗[/red] Update failed: {message}") - console.print("Please try updating manually or check the error messages above") + get_console().print(f"\n[red]✗[/red] Update failed: {message}") + get_console().print( + "Please try updating manually or check the error messages above" + ) raise typer.Exit(1) diff --git a/kicad_lib_manager/main.py b/kicad_lib_manager/main.py index 574faf4..98fc7af 100644 --- a/kicad_lib_manager/main.py +++ b/kicad_lib_manager/main.py @@ -8,11 +8,11 @@ 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 kicad_lib_manager.utils.console import get_console + from .commands.add_3d import add_3d from .commands.add_hook import add_hook from .commands.config import config_app @@ -32,12 +32,6 @@ TAGLINE = "Professional KiCad library management" -# Install rich traceback handler for better error display -install(show_locals=True) - -# Initialize Rich console -console = Console() - def _check_for_updates_if_needed() -> None: """Check for updates if configured to do so, non-intrusively.""" @@ -74,6 +68,7 @@ class BannerGroup(TyperGroup): def format_help(self, ctx, formatter): # Show banner before help + console = get_console() show_banner(console, justify="center") console.print() console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) @@ -98,6 +93,7 @@ def format_help(self, ctx, formatter): def version_callback(value: bool) -> None: """Print version information and exit.""" if value: + console = get_console() show_banner(console, justify="left") console.print() @@ -135,6 +131,7 @@ def main( import sys if not sys.argv[1:]: + console = get_console() show_banner(console) console.print() console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) 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/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/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 From 0ce114db873960b324a875d3d2a2662bb5ecd598 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:24:19 +0200 Subject: [PATCH 003/105] style: apply safe ruff fixes and formatting repo-wide Output of `ruff check --fix` (safe fixes only) and `ruff format` under the new rule set; no behavior change, the tests pass unchanged. --- kicad_lib_manager/__init__.py | 4 +- kicad_lib_manager/__main__.py | 4 +- kicad_lib_manager/commands/__init__.py | 4 +- kicad_lib_manager/commands/add_3d/command.py | 4 +- .../commands/add_hook/command.py | 7 +- kicad_lib_manager/commands/config/__init__.py | 2 +- kicad_lib_manager/commands/config/command.py | 16 ++-- kicad_lib_manager/commands/init/command.py | 6 +- .../commands/list_libraries/command.py | 7 +- kicad_lib_manager/commands/pin/command.py | 7 +- kicad_lib_manager/commands/setup/command.py | 7 +- kicad_lib_manager/commands/status/command.py | 4 +- kicad_lib_manager/commands/sync/command.py | 18 ++--- .../commands/template/__init__.py | 2 +- .../commands/template/command.py | 42 ++++------ kicad_lib_manager/commands/unpin/command.py | 4 +- kicad_lib_manager/commands/update/__init__.py | 2 +- kicad_lib_manager/commands/update/check.py | 14 ++-- kicad_lib_manager/commands/update/command.py | 3 +- kicad_lib_manager/commands/update/info.py | 1 - kicad_lib_manager/commands/update/perform.py | 1 - .../interfaces/config_service.py | 4 +- kicad_lib_manager/interfaces/kicad_service.py | 10 +-- .../interfaces/library_service.py | 13 +--- kicad_lib_manager/main.py | 7 +- kicad_lib_manager/services/config_service.py | 67 ++++++---------- kicad_lib_manager/services/kicad_service.py | 4 +- kicad_lib_manager/services/library_service.py | 33 +++----- kicad_lib_manager/services/update_service.py | 31 +++----- kicad_lib_manager/utils/__init__.py | 4 +- kicad_lib_manager/utils/backup.py | 10 +-- kicad_lib_manager/utils/banner.py | 7 +- kicad_lib_manager/utils/constants.py | 4 +- kicad_lib_manager/utils/env_vars.py | 16 ++-- kicad_lib_manager/utils/file_ops.py | 37 +++------ kicad_lib_manager/utils/git_utils.py | 15 ++-- kicad_lib_manager/utils/metadata.py | 25 ++---- kicad_lib_manager/utils/template.py | 78 +++++++------------ tests/test_config_commands.py | 4 +- tests/test_git_utils.py | 4 +- tests/test_sync_command.py | 8 +- tests/test_template.py | 4 +- tests/test_unpin_command.py | 4 +- tests/test_update_commands.py | 4 +- tests/test_update_service.py | 4 +- 45 files changed, 188 insertions(+), 368 deletions(-) diff --git a/kicad_lib_manager/__init__.py b/kicad_lib_manager/__init__.py index 0198006..edd21cd 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 - Library management utilities for KiCad""" diff --git a/kicad_lib_manager/__main__.py b/kicad_lib_manager/__main__.py index 840bbfa..f532067 100644 --- a/kicad_lib_manager/__main__.py +++ b/kicad_lib_manager/__main__.py @@ -1,6 +1,4 @@ -""" -Entry point for running the package as a module -""" +"""Entry point for running the package as a module""" from .main import 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/command.py b/kicad_lib_manager/commands/add_3d/command.py index dfd8cd1..3fa5bf9 100644 --- a/kicad_lib_manager/commands/add_3d/command.py +++ b/kicad_lib_manager/commands/add_3d/command.py @@ -1,6 +1,4 @@ -""" -Add cloud-based 3D models directory command for KiCad Library Manager (Typer version). -""" +"""Add cloud-based 3D models directory command for KiCad Library Manager (Typer version).""" from pathlib import Path from typing import Annotated diff --git a/kicad_lib_manager/commands/add_hook/command.py b/kicad_lib_manager/commands/add_hook/command.py index 56fc48b..cae540d 100644 --- a/kicad_lib_manager/commands/add_hook/command.py +++ b/kicad_lib_manager/commands/add_hook/command.py @@ -1,5 +1,4 @@ -""" -Add Hook command implementation for KiCad Library Manager. +"""Add Hook command implementation for KiCad Library Manager. Adds a git post-merge hook to the current repository to automatically update KiCad libraries. """ @@ -40,7 +39,7 @@ def add_hook( """ try: # Determine target directory - target_dir = directory if directory else Path.cwd() + target_dir = directory or Path.cwd() get_console().print(f"[cyan]Adding Git hook to repository: {target_dir}[/cyan]") @@ -131,7 +130,7 @@ def add_hook( ) except Exception as e: - get_console().print(f"[red]Error creating hook: {str(e)}[/red]") + get_console().print(f"[red]Error creating hook: {e!s}[/red]") raise typer.Exit(1) from e except Exception as e: diff --git a/kicad_lib_manager/commands/config/__init__.py b/kicad_lib_manager/commands/config/__init__.py index e5b7a23..228701e 100644 --- a/kicad_lib_manager/commands/config/__init__.py +++ b/kicad_lib_manager/commands/config/__init__.py @@ -17,4 +17,4 @@ ) 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 cdcb4ce..de674e9 100644 --- a/kicad_lib_manager/commands/config/command.py +++ b/kicad_lib_manager/commands/config/command.py @@ -1,5 +1,4 @@ -""" -Configuration commands implementation for KiCad Library Manager. +"""Configuration commands implementation for KiCad Library Manager. Provides commands for managing KiCad Library Manager configuration. """ @@ -108,7 +107,7 @@ def list_config( content += ( f"[yellow]Version:[/yellow] {metadata['version']}\n" ) - if "env_var" in metadata and metadata["env_var"]: + if metadata.get("env_var"): content += f"[magenta]Environment Variable:[/magenta] {metadata['env_var']}\n" # Capabilities with clear labels @@ -203,7 +202,7 @@ def list_config( content += ( f"[yellow]Version:[/yellow] {metadata['version']}\n" ) - if "env_var" in metadata and metadata["env_var"]: + if metadata.get("env_var"): content += f"[magenta]Environment Variable:[/magenta] {metadata['env_var']}\n" # Model count @@ -337,7 +336,6 @@ def set_default( from the available libraries of the specified type. Examples: - \b # Set a GitHub library as default kilm config set-default my-library @@ -400,10 +398,9 @@ def set_default( library_name = selected_lib.get("name") library_path = selected_lib.get("path") break - else: - get_console().print( - f"Please enter a number between 1 and {len(libraries)}" - ) + get_console().print( + f"Please enter a number between 1 and {len(libraries)}" + ) except ValueError: get_console().print("Please enter a valid number") else: @@ -457,7 +454,6 @@ def remove( This does not delete any files, it only removes the library from the configuration. Examples: - \b # Remove a library (prompts for confirmation) kilm config remove my-library diff --git a/kicad_lib_manager/commands/init/command.py b/kicad_lib_manager/commands/init/command.py index 154acb1..ce193cf 100644 --- a/kicad_lib_manager/commands/init/command.py +++ b/kicad_lib_manager/commands/init/command.py @@ -1,5 +1,4 @@ -""" -Init command implementation for KiCad Library Manager (Typer version). +"""Init command implementation for KiCad Library Manager (Typer version). Initializes the current directory as a KiCad library directory (symbols, footprints, templates). """ @@ -60,8 +59,7 @@ def init( ), ] = False, ) -> None: - """ - Initialize the current directory as a KiCad library collection. + """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 diff --git a/kicad_lib_manager/commands/list_libraries/command.py b/kicad_lib_manager/commands/list_libraries/command.py index 8de7053..22d6154 100644 --- a/kicad_lib_manager/commands/list_libraries/command.py +++ b/kicad_lib_manager/commands/list_libraries/command.py @@ -1,6 +1,4 @@ -""" -List command implementation for KiCad Library Manager (Typer version). -""" +"""List command implementation for KiCad Library Manager (Typer version).""" from pathlib import Path from typing import Annotated @@ -24,8 +22,7 @@ def list_cmd( ), ] = 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. diff --git a/kicad_lib_manager/commands/pin/command.py b/kicad_lib_manager/commands/pin/command.py index 51e4716..0c9ff38 100644 --- a/kicad_lib_manager/commands/pin/command.py +++ b/kicad_lib_manager/commands/pin/command.py @@ -1,6 +1,4 @@ -""" -Pin command implementation for KiCad Library Manager (Typer version). -""" +"""Pin command implementation for KiCad Library Manager (Typer version).""" from pathlib import Path from typing import Annotated @@ -75,8 +73,7 @@ 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. diff --git a/kicad_lib_manager/commands/setup/command.py b/kicad_lib_manager/commands/setup/command.py index 17b8242..99237ef 100644 --- a/kicad_lib_manager/commands/setup/command.py +++ b/kicad_lib_manager/commands/setup/command.py @@ -1,6 +1,4 @@ -""" -Setup command implementation for KiCad Library Manager (Typer version). -""" +"""Setup command implementation for KiCad Library Manager (Typer version).""" import re from pathlib import Path @@ -30,8 +28,7 @@ def fix_invalid_uris( max_backups: int = 5, dry_run: bool = False, ) -> bool: - """ - Fix invalid URIs in KiCad library tables, such as paths incorrectly wrapped in ${} syntax. + """Fix invalid URIs in KiCad library tables, such as paths incorrectly wrapped in ${} syntax. Args: kicad_config: Path to the KiCad configuration directory diff --git a/kicad_lib_manager/commands/status/command.py b/kicad_lib_manager/commands/status/command.py index b067adc..d349cbd 100644 --- a/kicad_lib_manager/commands/status/command.py +++ b/kicad_lib_manager/commands/status/command.py @@ -1,6 +1,4 @@ -""" -Status command implementation for KiCad Library Manager (Typer version). -""" +"""Status command implementation for KiCad Library Manager (Typer version).""" import json from pathlib import Path diff --git a/kicad_lib_manager/commands/sync/command.py b/kicad_lib_manager/commands/sync/command.py index df4d55d..d22e039 100644 --- a/kicad_lib_manager/commands/sync/command.py +++ b/kicad_lib_manager/commands/sync/command.py @@ -1,5 +1,4 @@ -""" -Sync command implementation for KiCad Library Manager. +"""Sync command implementation for KiCad Library Manager. Performs 'git pull' on all configured GitHub libraries (symbols/footprints). """ @@ -118,11 +117,10 @@ def sync( get_console().print(" [green]Updated[/green]") else: get_console().print(" [blue]Up to date[/blue]") + elif is_updated: + get_console().print(" [green]Updated[/green]") else: - if is_updated: - get_console().print(" [green]Updated[/green]") - else: - get_console().print(" [blue]Up to date[/blue]") + get_console().print(" [blue]Up to date[/blue]") # Update counters regardless of verbose flag if is_updated: @@ -148,7 +146,7 @@ def sync( failed_count += 1 except Exception as e: - get_console().print(f" [red]Error: {str(e)}[/red]") + get_console().print(f" [red]Error: {e!s}[/red]") failed_count += 1 # Summary @@ -201,8 +199,7 @@ def sync( # 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. + """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: @@ -286,8 +283,7 @@ def _is_template_change(path: str) -> bool: def _check_current_library_state(lib_path: Path) -> list[str]: - """ - Fallback method to check current library state when git diff is not available. + """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 = [] diff --git a/kicad_lib_manager/commands/template/__init__.py b/kicad_lib_manager/commands/template/__init__.py index 0c1fd19..686fc11 100644 --- a/kicad_lib_manager/commands/template/__init__.py +++ b/kicad_lib_manager/commands/template/__init__.py @@ -19,4 +19,4 @@ 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 b270aea..0339957 100644 --- a/kicad_lib_manager/commands/template/command.py +++ b/kicad_lib_manager/commands/template/command.py @@ -1,5 +1,4 @@ -""" -Template commands implementation for KiCad Library Manager. +"""Template commands implementation for KiCad Library Manager. Provides commands for creating KiCad projects from templates and creating templates from projects. """ @@ -45,7 +44,6 @@ def main_callback() -> None: This command group allows you to create new KiCad projects from templates, and create new templates from existing projects. """ - pass def create( @@ -86,7 +84,6 @@ def create( 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 @@ -415,15 +412,14 @@ def create( ): get_console().print("Aborted.") return - else: - get_console().print(f"Removing existing directory: {final_project_dir}") - try: - shutil.rmtree(final_project_dir) - except Exception as e: - get_console(stderr=True).print( - f"Error removing existing directory: {e}" - ) - return + get_console().print(f"Removing existing directory: {final_project_dir}") + try: + shutil.rmtree(final_project_dir) + except Exception as e: + get_console(stderr=True).print( + f"Error removing existing directory: {e}" + ) + return # Create parent directories if they don't exist try: @@ -557,7 +553,6 @@ def make( 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 @@ -644,10 +639,9 @@ def make( selected_lib = library_names[lib_selection - 1] library_path = library_paths[selected_lib] break - else: - get_console().print( - f"Please enter a number between 1 and {len(library_names)}" - ) + get_console().print( + f"Please enter a number between 1 and {len(library_names)}" + ) except ValueError: get_console().print("Please enter a valid number") @@ -796,11 +790,8 @@ def make( if not git_path.endswith("/"): git_path += "/" - if ( - gitignore_spec - and gitignore_spec.match_file(git_path) - or additional_spec - and additional_spec.match_file(git_path) + if (gitignore_spec and gitignore_spec.match_file(git_path)) or ( + additional_spec and additional_spec.match_file(git_path) ): dirs_to_remove.append(d) excluded_files.append(f"{rel_path}/") @@ -919,7 +910,7 @@ def make( ) except Exception as e: - get_console(stderr=True).print(f"Error creating template: {str(e)}") + get_console(stderr=True).print(f"Error creating template: {e!s}") traceback.print_exc() sys.exit(1) @@ -944,7 +935,6 @@ def list_templates( with their descriptions, source libraries, and other metadata. Examples: - \b # List all templates kilm template list @@ -1059,7 +1049,7 @@ def list_templates( var_display = "[dim]None[/dim]" row_data.append(var_display) - row_data.append(extends if extends else "[dim]None[/dim]") + row_data.append(extends or "[dim]None[/dim]") table.add_row(*row_data) diff --git a/kicad_lib_manager/commands/unpin/command.py b/kicad_lib_manager/commands/unpin/command.py index a464e1c..d665d08 100644 --- a/kicad_lib_manager/commands/unpin/command.py +++ b/kicad_lib_manager/commands/unpin/command.py @@ -1,6 +1,4 @@ -""" -Unpin command implementation for KiCad Library Manager (Typer version). -""" +"""Unpin command implementation for KiCad Library Manager (Typer version).""" import json diff --git a/kicad_lib_manager/commands/update/__init__.py b/kicad_lib_manager/commands/update/__init__.py index 73ffd45..1a8ab38 100644 --- a/kicad_lib_manager/commands/update/__init__.py +++ b/kicad_lib_manager/commands/update/__init__.py @@ -6,8 +6,8 @@ from .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 63c1a39..f682824 100644 --- a/kicad_lib_manager/commands/update/check.py +++ b/kicad_lib_manager/commands/update/check.py @@ -20,7 +20,6 @@ def check_update_command( ] = False, ) -> None: """Check for available updates without installing.""" - version = importlib.metadata.version("kilm") update_service = UpdateService(version) @@ -48,10 +47,9 @@ def check_update_command( # Exit with code 1 to indicate update available raise typer.Exit(1) - else: - if not quiet: - get_console().print( - f"[green]✓[/green] You are using the latest version ([bold cyan]v{update_info['current_version']}[/bold cyan])" - ) - # Exit with code 0 for no update needed - raise typer.Exit(0) + if not quiet: + get_console().print( + f"[green]✓[/green] You are using the latest version ([bold cyan]v{update_info['current_version']}[/bold cyan])" + ) + # Exit with code 0 for no update needed + raise typer.Exit(0) diff --git a/kicad_lib_manager/commands/update/command.py b/kicad_lib_manager/commands/update/command.py index dd2e141..66dac9a 100644 --- a/kicad_lib_manager/commands/update/command.py +++ b/kicad_lib_manager/commands/update/command.py @@ -1,5 +1,4 @@ -""" -Update command group for KiCad Library Manager. +"""Update command group for KiCad Library Manager. Contains subcommands for checking, showing info, and performing updates. """ diff --git a/kicad_lib_manager/commands/update/info.py b/kicad_lib_manager/commands/update/info.py index cf4cb6c..401bd14 100644 --- a/kicad_lib_manager/commands/update/info.py +++ b/kicad_lib_manager/commands/update/info.py @@ -17,7 +17,6 @@ def info_command( ] = False, ) -> None: """Show detailed installation and update information.""" - version = importlib.metadata.version("kilm") update_service = UpdateService(version) diff --git a/kicad_lib_manager/commands/update/perform.py b/kicad_lib_manager/commands/update/perform.py index c5a9db2..1f26ae2 100644 --- a/kicad_lib_manager/commands/update/perform.py +++ b/kicad_lib_manager/commands/update/perform.py @@ -44,7 +44,6 @@ def perform_update_command( To update library content, use 'kilm sync' instead. This banner will be removed in a future version. """ - # Show deprecation notice deprecation_notice = ( "[bold yellow]⚠️ BREAKING CHANGE NOTICE (KiLM 0.4.0)[/bold yellow]\n\n" diff --git a/kicad_lib_manager/interfaces/config_service.py b/kicad_lib_manager/interfaces/config_service.py index 94ed315..f90521d 100644 --- a/kicad_lib_manager/interfaces/config_service.py +++ b/kicad_lib_manager/interfaces/config_service.py @@ -1,6 +1,4 @@ -""" -Configuration service protocol interface for KiCad Library Manager. -""" +"""Configuration service protocol interface for KiCad Library Manager.""" from abc import abstractmethod from pathlib import Path diff --git a/kicad_lib_manager/interfaces/kicad_service.py b/kicad_lib_manager/interfaces/kicad_service.py index 179dcd3..e77bf6b 100644 --- a/kicad_lib_manager/interfaces/kicad_service.py +++ b/kicad_lib_manager/interfaces/kicad_service.py @@ -1,6 +1,4 @@ -""" -KiCad service protocol interface for KiCad Library Manager. -""" +"""KiCad service protocol interface for KiCad Library Manager.""" from abc import abstractmethod from pathlib import Path @@ -32,8 +30,7 @@ def set_environment_variables( def get_configured_libraries( self, config_dir: Path ) -> tuple[list[dict], list[dict]]: - """ - Get configured symbol and footprint libraries. + """Get configured symbol and footprint libraries. Returns: Tuple of (symbol_libraries, footprint_libraries) @@ -52,8 +49,7 @@ def add_libraries_to_kicad( @abstractmethod def get_pinned_libraries(self, config_dir: Path) -> tuple[list[str], list[str]]: - """ - Get pinned libraries from KiCad configuration. + """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 index 06b4cc1..16c6785 100644 --- a/kicad_lib_manager/interfaces/library_service.py +++ b/kicad_lib_manager/interfaces/library_service.py @@ -1,6 +1,4 @@ -""" -Library service protocol interface for KiCad Library Manager. -""" +"""Library service protocol interface for KiCad Library Manager.""" from abc import abstractmethod from pathlib import Path @@ -12,8 +10,7 @@ class LibraryServiceProtocol(Protocol): @abstractmethod def list_libraries(self, directory: Path) -> tuple[list[str], list[str]]: - """ - List symbol and footprint libraries in a directory. + """List symbol and footprint libraries in a directory. Returns: Tuple of (symbol_libraries, footprint_libraries) @@ -49,8 +46,7 @@ def pin_libraries( dry_run: bool = False, max_backups: int = 5, ) -> bool: - """ - Pin libraries in KiCad for quick access. + """Pin libraries in KiCad for quick access. Returns: True if changes were made, False otherwise @@ -66,8 +62,7 @@ def unpin_libraries( dry_run: bool = False, max_backups: int = 5, ) -> bool: - """ - Unpin libraries in KiCad. + """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 98fc7af..cf36d85 100644 --- a/kicad_lib_manager/main.py +++ b/kicad_lib_manager/main.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -""" -Main Typer CLI entry point for KiCad Library Manager -""" +"""Main Typer CLI entry point for KiCad Library Manager""" import importlib.metadata from typing import Annotated @@ -115,8 +113,7 @@ def main( ), ] = None, ) -> None: - """ - [bold blue]KiCad Library Manager[/bold blue] - Professional KiCad library management + """[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. diff --git a/kicad_lib_manager/services/config_service.py b/kicad_lib_manager/services/config_service.py index 02586b3..95e39cb 100644 --- a/kicad_lib_manager/services/config_service.py +++ b/kicad_lib_manager/services/config_service.py @@ -1,6 +1,4 @@ -""" -Configuration management for KiCad Library Manager. -""" +"""Configuration management for KiCad Library Manager.""" import time from pathlib import Path @@ -37,9 +35,7 @@ class LibraryDict(TypedDict): class Config: - """ - Configuration manager for KiCad Library Manager - """ + """Configuration manager for KiCad Library Manager""" def __init__(self): """Initialize configuration with default values""" @@ -82,8 +78,7 @@ def save(self) -> None: yaml.dump(self._config, f, default_flow_style=False) def add_library(self, name: str, path: str, library_type: str = "github") -> None: - """ - Add a library to the configuration + """Add a library to the configuration Args: name: Library name @@ -107,8 +102,7 @@ def add_library(self, name: str, path: str, library_type: str = "github") -> Non self.save() def remove_library(self, name: str, library_type: str | None = None) -> bool: - """ - Remove a library from the configuration + """Remove a library from the configuration Args: name: Library name @@ -137,8 +131,7 @@ def remove_library(self, name: str, library_type: str | None = None) -> bool: return removed def get_libraries(self, library_type: str | None = None) -> list[LibraryDict]: - """ - Get libraries from configuration + """Get libraries from configuration Args: library_type: Type of library ("github" or "cloud"). If None, get all types. @@ -150,12 +143,10 @@ def get_libraries(self, library_type: str | None = None) -> list[LibraryDict]: if library_type: return [lib for lib in libraries if lib["type"] == library_type] - else: - return libraries + return libraries def get_library_path(self, name: str, library_type: str = "github") -> str | None: - """ - Get the path for a specific library + """Get the path for a specific library Args: name: Library name @@ -173,8 +164,7 @@ def get_library_path(self, name: str, library_type: str = "github") -> str | Non return None def get_current_library_paths(self) -> tuple[str | None, str | None]: - """ - Get paths for the current active libraries + """Get paths for the current active libraries Returns: Tuple of (github_library_path, cloud_library_path), either may be None @@ -219,8 +209,7 @@ def get_current_library_paths(self) -> tuple[str | None, str | None]: return github_lib_path, cloud_lib_path def set_current_library(self, path: str) -> None: - """ - Set the current active library path + """Set the current active library path Args: path: Path to the library @@ -229,8 +218,7 @@ def set_current_library(self, path: str) -> None: self.save() def get_current_library(self) -> str | None: - """ - Get the current active library path + """Get the current active library path Returns: Path to the current library or None if not set @@ -239,8 +227,7 @@ def get_current_library(self) -> str | None: return value if isinstance(value, str) else None def get_symbol_library_path(self) -> str | None: - """ - Get the path for the current symbol library + """Get the path for the current symbol library Returns: Path to the symbol library or None if not found @@ -249,8 +236,7 @@ def get_symbol_library_path(self) -> str | None: return github_path def get_3d_library_path(self) -> str | None: - """ - Get the path for the current 3D models library + """Get the path for the current 3D models library Returns: Path to the 3D models library or None if not found @@ -283,8 +269,7 @@ def _normalize_libraries_field(self) -> None: self._config["libraries"] = normalized_libraries def _get_normalized_libraries(self) -> list[LibraryDict]: - """ - Get libraries ensuring they are properly normalized. + """Get libraries ensuring they are properly normalized. Returns: List of properly typed library dictionaries @@ -320,8 +305,7 @@ def _get_normalized_libraries(self) -> list[LibraryDict]: return normalized_libraries def should_check_updates(self) -> bool: - """ - Determine if update check should run based on configuration. + """Determine if update check should run based on configuration. Returns: True if update check should be performed @@ -346,7 +330,7 @@ def should_check_updates(self) -> bool: if frequency == "daily": return now - last_check > 86400 # 24 hours - elif frequency == "weekly": + if frequency == "weekly": return now - last_check > 604800 # 7 days except (ValueError, OSError): return True @@ -355,15 +339,13 @@ def should_check_updates(self) -> bool: 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. + """Get update-related preferences. Returns: Dictionary containing update preferences @@ -375,8 +357,7 @@ def get_update_preferences(self) -> dict[str, bool | str]: } def set_update_preference(self, key: str, value: bool | str) -> None: - """ - Set an update preference with strict type validation and coercion. + """Set an update preference with strict type validation and coercion. Args: key: Preference key ('update_check', 'update_check_frequency', 'auto_update') @@ -408,8 +389,7 @@ def set_update_preference(self, key: str, value: bool | str) -> None: self.save() def _coerce_to_bool(self, value: bool | str) -> bool: - """ - Coerce a value to boolean with strict validation. + """Coerce a value to boolean with strict validation. Args: value: Value to coerce (bool or string) @@ -427,13 +407,12 @@ def _coerce_to_bool(self, value: bool | str) -> bool: value_lower = value.lower().strip() if value_lower in {"true", "1", "yes"}: return True - elif value_lower in {"false", "0", "no"}: + if 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)" - ) + 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( diff --git a/kicad_lib_manager/services/kicad_service.py b/kicad_lib_manager/services/kicad_service.py index 0d0d1c4..234335f 100644 --- a/kicad_lib_manager/services/kicad_service.py +++ b/kicad_lib_manager/services/kicad_service.py @@ -1,6 +1,4 @@ -""" -KiCad service implementation for KiCad Library Manager. -""" +"""KiCad service implementation for KiCad Library Manager.""" import json from pathlib import Path diff --git a/kicad_lib_manager/services/library_service.py b/kicad_lib_manager/services/library_service.py index aac6da7..19f2d06 100644 --- a/kicad_lib_manager/services/library_service.py +++ b/kicad_lib_manager/services/library_service.py @@ -1,5 +1,4 @@ -""" -Library service implementation for KiCad Library Manager. +"""Library service implementation for KiCad Library Manager. Core functionality for managing KiCad libraries. """ @@ -151,8 +150,7 @@ def add_libraries( additional_3d_dirs: dict[str, str] | None = None, dry_run: bool = False, ) -> tuple[set[str], bool]: - """ - Add KiCad libraries to the configuration. + """Add KiCad libraries to the configuration. Args: kicad_lib_dir: Path to the KiCad library directory @@ -322,8 +320,7 @@ def _kicad_version_key(version_dir: Path) -> tuple[int, ...]: @staticmethod def find_kicad_config() -> Path: - """ - Find the KiCad configuration directory for the current platform + """Find the KiCad configuration directory for the current platform Returns: Path to the KiCad configuration directory @@ -388,8 +385,7 @@ def find_kicad_config() -> Path: 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 + """Get a description for a library from the YAML file or generate a default one Args: lib_type: Either 'symbols' or 'footprints' @@ -421,13 +417,11 @@ def get_library_description( # 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 f"{lib_name} footprint library" @staticmethod def format_uri(base_path: str, lib_name: str, lib_type: str) -> str: - """ - Format a URI for a KiCad library. + """Format a URI for a KiCad library. Args: base_path: The base path to the library directory @@ -469,22 +463,19 @@ def is_absolute_path(path: str) -> bool: # 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}}}" + # If it's not in ${...} format, check if it's an absolute path + elif 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" - else: - return f"{base_path}/footprints/{lib_name}.pretty" + 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 + """Add entries to a KiCad library table file Args: table_path: Path to the library table diff --git a/kicad_lib_manager/services/update_service.py b/kicad_lib_manager/services/update_service.py index 6f08ff7..029bbf8 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -1,5 +1,4 @@ -""" -Auto-update functionality for KiLM. +"""Auto-update functionality for KiLM. Handles installation method detection, PyPI integration, and update execution. """ @@ -102,8 +101,7 @@ def detect_homebrew(self) -> bool: return any(self.executable_str.startswith(path) for path in homebrew_paths) def detect(self) -> str: - """ - Detect installation method with priority order. + """Detect installation method with priority order. Returns: 'pipx' | 'conda' | 'uv' | 'pip-venv' | 'homebrew' | 'pip' """ # Priority order matters - more specific first @@ -127,8 +125,7 @@ def detect(self) -> str: # TODO: Add strong typing def detect_installation_method() -> str: - """ - Detect how KiLM was installed to determine appropriate update strategy. + """Detect how KiLM was installed to determine appropriate update strategy. Returns: 'pipx' | 'pip' | 'pip-venv' | 'uv' | 'conda' | 'homebrew' """ detector = InstallationDetector() @@ -145,8 +142,7 @@ def __init__(self, package_name: str, version: str = "unknown"): 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. + """Check latest version from PyPI with caching and rate limiting. Returns None if check fails or is rate limited. """ try: @@ -281,8 +277,7 @@ def can_auto_update(self) -> bool: return self.installation_method in ["pipx", "pip", "pip-venv", "uv"] def perform_update(self) -> tuple[bool, str]: - """ - Execute update using detected installation method. + """Execute update using detected installation method. Returns: (success: bool, message: str) """ if not self.can_auto_update(): @@ -302,11 +297,10 @@ def perform_update(self) -> tuple[bool, str]: 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}" + 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)}" + return False, f"Update error: {e!s}" else: instruction = self.get_update_instruction() return False, f"Unsupported installation method. Run: {instruction}" @@ -320,8 +314,7 @@ def __init__(self, current_version: str): self.current_version = current_version def check_for_updates(self, use_cache: bool = True) -> dict[str, Any]: - """ - Check for available updates. + """Check for available updates. Returns: Dict containing update status information: @@ -352,8 +345,7 @@ def check_for_updates(self, use_cache: bool = True) -> dict[str, Any]: def show_update_notification( self, quiet: bool = False, force_check: bool = False ) -> bool: - """ - Show update notification if updates are available. + """Show update notification if updates are available. Args: quiet: Only show notification if update is available @@ -435,8 +427,7 @@ def perform_update( force: bool = False, dry_run: bool = False, ) -> tuple[bool, str]: - """ - Perform update installation. + """Perform update installation. Args: target_version: Specific version to install diff --git a/kicad_lib_manager/utils/__init__.py b/kicad_lib_manager/utils/__init__.py index c6a00df..f30ce6e 100644 --- a/kicad_lib_manager/utils/__init__.py +++ b/kicad_lib_manager/utils/__init__.py @@ -1,6 +1,4 @@ -""" -Utility functions for KiCad Library Manager -""" +"""Utility functions for KiCad Library Manager""" from .banner import show_banner diff --git a/kicad_lib_manager/utils/backup.py b/kicad_lib_manager/utils/backup.py index 71b79b0..c202ddc 100644 --- a/kicad_lib_manager/utils/backup.py +++ b/kicad_lib_manager/utils/backup.py @@ -1,6 +1,4 @@ -""" -Utilities for backup management -""" +"""Utilities for backup management""" import shutil from datetime import datetime @@ -8,8 +6,7 @@ def create_backup(file_path: Path, max_backups: int = 5) -> Path: - """ - Create a timestamped backup of a file and maintain a limited history + """Create a timestamped backup of a file and maintain a limited history Args: file_path: Path to the file to back up @@ -38,8 +35,7 @@ def create_backup(file_path: Path, max_backups: int = 5) -> Path: def manage_backup_history(file_path: Path, max_backups: int = 5) -> None: - """ - Maintain a limited history of backups, removing the oldest ones + """Maintain a limited history of backups, removing the oldest ones Args: file_path: Path to the original file diff --git a/kicad_lib_manager/utils/banner.py b/kicad_lib_manager/utils/banner.py index 2007bae..b2d9373 100644 --- a/kicad_lib_manager/utils/banner.py +++ b/kicad_lib_manager/utils/banner.py @@ -1,6 +1,4 @@ -""" -Banner utilities for KiCad Library Manager -""" +"""Banner utilities for KiCad Library Manager""" from typing import Literal @@ -10,8 +8,7 @@ def show_banner( console: Console, justify: Literal["center", "left", "right"] = "center" ) -> None: - """ - Display the KiLM banner with colorful ASCII art. + """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. diff --git a/kicad_lib_manager/utils/constants.py b/kicad_lib_manager/utils/constants.py index 6184746..a38f018 100644 --- a/kicad_lib_manager/utils/constants.py +++ b/kicad_lib_manager/utils/constants.py @@ -1,6 +1,4 @@ -""" -Constants used throughout KiCad Library Manager. -""" +"""Constants used throughout KiCad Library Manager.""" # Metadata filenames GITHUB_METADATA_FILE = "kilm.yaml" diff --git a/kicad_lib_manager/utils/env_vars.py b/kicad_lib_manager/utils/env_vars.py index 9832de9..e5cebc3 100644 --- a/kicad_lib_manager/utils/env_vars.py +++ b/kicad_lib_manager/utils/env_vars.py @@ -1,6 +1,4 @@ -""" -Environment variable handling utilities -""" +"""Environment variable handling utilities""" import json import os @@ -31,8 +29,7 @@ def find_environment_variables(var_name: str) -> str | None: - """ - Find environment variables from various sources in the following order: + """Find environment variables from various sources in the following order: 1. Config file 2. Metadata files in current directory 3. Environment variables @@ -131,8 +128,7 @@ def find_environment_variables(var_name: str) -> str | None: def expand_user_path(path: str) -> str: - """ - Expand a path that might start with ~ to an absolute path + """Expand a path that might start with ~ to an absolute path Args: path: The path to expand @@ -148,8 +144,7 @@ def expand_user_path(path: str) -> str: def update_kicad_env_vars( kicad_config: Path, env_vars: dict, dry_run: bool = False, max_backups: int = 5 ) -> bool: - """ - Update environment variables in KiCad's common configuration file + """Update environment variables in KiCad's common configuration file Args: kicad_config: Path to the KiCad configuration directory @@ -234,8 +229,7 @@ def update_pinned_libraries( dry_run: bool = False, max_backups: int = 5, ) -> bool: - """ - Update pinned libraries in KiCad's common configuration file + """Update pinned libraries in KiCad's common configuration file Args: kicad_config: Path to the KiCad configuration directory diff --git a/kicad_lib_manager/utils/file_ops.py b/kicad_lib_manager/utils/file_ops.py index 8e312cc..a9ee97d 100644 --- a/kicad_lib_manager/utils/file_ops.py +++ b/kicad_lib_manager/utils/file_ops.py @@ -1,6 +1,4 @@ -""" -File operation utilities -""" +"""File operation utilities""" import os import re @@ -10,8 +8,7 @@ def read_file_with_encoding(file_path: Path, encodings: list[str] | None = None) -> str: - """ - Read a file trying multiple encodings until successful + """Read a file trying multiple encodings until successful Args: file_path: Path to the file to read @@ -43,8 +40,7 @@ def read_file_with_encoding(file_path: Path, encodings: list[str] | None = None) def write_file_with_encoding( file_path: Path, content: str, encoding: str = "utf-8" ) -> None: - """ - Write content to a file with the specified encoding + """Write content to a file with the specified encoding Args: file_path: Path to the file to write @@ -83,8 +79,7 @@ def write_atomically(path: Path, data: bytes) -> None: def validate_lib_table(table_path: Path, create_if_missing: bool = True) -> None: - """ - Validate a KiCad library table file + """Validate a KiCad library table file Args: table_path: Path to the library table @@ -122,8 +117,7 @@ def add_symbol_lib( sym_table: Path, dry_run: bool = False, ) -> bool: - """ - Add a symbol library to the KiCad configuration + """Add a symbol library to the KiCad configuration Args: lib_name: The name of the library @@ -160,10 +154,7 @@ def add_symbol_lib( write_file_with_encoding(sym_table, "\n".join(lines) + "\n") return True - else: - raise ValueError( - "Invalid symbol library table format: missing closing parenthesis" - ) + raise ValueError("Invalid symbol library table format: missing closing parenthesis") def add_footprint_lib( @@ -173,8 +164,7 @@ def add_footprint_lib( fp_table: Path, dry_run: bool = False, ) -> bool: - """ - Add a footprint library to the KiCad configuration + """Add a footprint library to the KiCad configuration Args: lib_name: The name of the library @@ -211,15 +201,13 @@ def add_footprint_lib( write_file_with_encoding(fp_table, "\n".join(lines) + "\n") return True - else: - raise ValueError( - "Invalid footprint library table format: missing closing parenthesis" - ) + raise ValueError( + "Invalid footprint library table format: missing closing parenthesis" + ) def list_libraries(kicad_lib_dir: str) -> tuple[list[str], list[str]]: - """ - List all available libraries in the repository + """List all available libraries in the repository Args: kicad_lib_dir: The KiCad library directory @@ -252,8 +240,7 @@ def list_libraries(kicad_lib_dir: str) -> tuple[list[str], list[str]]: def list_configured_libraries(kicad_config: Path) -> tuple[list[dict], list[dict]]: - """ - List all libraries currently configured in KiCad + """List all libraries currently configured in KiCad Args: kicad_config: Path to the KiCad configuration directory diff --git a/kicad_lib_manager/utils/git_utils.py b/kicad_lib_manager/utils/git_utils.py index 25faea5..038f19c 100644 --- a/kicad_lib_manager/utils/git_utils.py +++ b/kicad_lib_manager/utils/git_utils.py @@ -1,5 +1,4 @@ -""" -Git utility functions for KiCad Library Manager. +"""Git utility functions for KiCad Library Manager. Handles Git hooks directory detection and safe hook management. """ @@ -10,8 +9,7 @@ def get_git_hooks_directory(repo_path: Path) -> Path: - """ - Get the active Git hooks directory for a repository. + """Get the active Git hooks directory for a repository. This function detects the correct hooks directory by: 1. Using git rev-parse --git-path hooks (handles core.hooksPath, worktrees, and common dir) @@ -62,8 +60,7 @@ def get_git_hooks_directory(repo_path: Path) -> Path: def backup_existing_hook(hook_path: Path) -> Path: - """ - Create a timestamped backup of an existing hook file. + """Create a timestamped backup of an existing hook file. Args: hook_path: Path to the existing hook file @@ -87,8 +84,7 @@ def backup_existing_hook(hook_path: Path) -> Path: def merge_hook_content(existing_content: str, kilm_content: str) -> str: - """ - Safely merge existing hook content with KiLM content. + """Safely merge existing hook content with KiLM content. Args: existing_content: Content of existing hook @@ -124,8 +120,7 @@ def merge_hook_content(existing_content: str, kilm_content: str) -> str: def create_kilm_hook_content() -> str: - """ - Create the standard KiLM hook content with clear markers. + """Create the standard KiLM hook content with clear markers. Returns: Formatted hook content string diff --git a/kicad_lib_manager/utils/metadata.py b/kicad_lib_manager/utils/metadata.py index 07001c7..2602d32 100644 --- a/kicad_lib_manager/utils/metadata.py +++ b/kicad_lib_manager/utils/metadata.py @@ -1,6 +1,4 @@ -""" -Metadata management utilities for KiCad Library Manager. -""" +"""Metadata management utilities for KiCad Library Manager.""" import json import re @@ -15,8 +13,7 @@ def read_github_metadata( directory: Path, ) -> dict[str, Any] | None: - """ - Read metadata from a GitHub library directory. + """Read metadata from a GitHub library directory. Args: directory: Path to the GitHub library directory @@ -42,8 +39,7 @@ def read_github_metadata( def write_github_metadata(directory: Path, metadata: dict[str, Any]) -> bool: - """ - Write metadata to a GitHub library directory. + """Write metadata to a GitHub library directory. Args: directory: Path to the GitHub library directory @@ -64,8 +60,7 @@ def write_github_metadata(directory: Path, metadata: dict[str, Any]) -> bool: def read_cloud_metadata(directory: Path) -> dict[str, str | int | None] | None: - """ - Read metadata from a cloud 3D model directory. + """Read metadata from a cloud 3D model directory. Args: directory: Path to the cloud 3D model directory @@ -93,8 +88,7 @@ def read_cloud_metadata(directory: Path) -> dict[str, str | int | None] | None: def write_cloud_metadata( directory: Path, metadata: dict[str, str | int | None] ) -> bool: - """ - Write metadata to a cloud 3D model directory. + """Write metadata to a cloud 3D model directory. Args: directory: Path to the cloud 3D model directory @@ -115,8 +109,7 @@ def write_cloud_metadata( def generate_env_var_name(name: str, prefix: str = "") -> str: - """ - Generate a valid environment variable name from a library name. + """Generate a valid environment variable name from a library name. Args: name: Library name @@ -146,8 +139,7 @@ def generate_env_var_name(name: str, prefix: str = "") -> str: def get_default_github_metadata( directory: Path, ) -> dict[str, Any]: - """ - Generate default metadata for a GitHub library. + """Generate default metadata for a GitHub library. Args: directory: Path to the GitHub library directory @@ -183,8 +175,7 @@ def get_default_github_metadata( def get_default_cloud_metadata(directory: Path) -> dict[str, str | int | None]: - """ - Generate default metadata for a cloud 3D model directory. + """Generate default metadata for a cloud 3D model directory. Args: directory: Path to the cloud 3D model directory diff --git a/kicad_lib_manager/utils/template.py b/kicad_lib_manager/utils/template.py index 999230d..9e69823 100644 --- a/kicad_lib_manager/utils/template.py +++ b/kicad_lib_manager/utils/template.py @@ -1,5 +1,4 @@ -""" -Template utility functions for KiCad Library Manager. +"""Template utility functions for KiCad Library Manager. Provides functions for working with project templates. """ @@ -126,8 +125,7 @@ class TemplateMetadata(TypedDict, total=False): def get_gitignore_spec(directory: Path) -> pathspec.PathSpec | None: - """ - Get a PathSpec object representing the gitignore patterns in the given directory. + """Get a PathSpec object representing the gitignore patterns in the given directory. Args: directory: Path to the directory containing .gitignore @@ -172,8 +170,7 @@ def get_gitignore_spec(directory: Path) -> pathspec.PathSpec | None: def list_templates_in_directory(directory: Path) -> list[TemplateMetadata]: - """ - List all templates in a given directory. + """List all templates in a given directory. Args: directory: Path to the directory to scan for templates @@ -219,8 +216,7 @@ def list_templates_in_directory(directory: Path) -> list[TemplateMetadata]: def find_potential_variables( directory: Path, patterns: list[str] | None = None ) -> dict[str, list[str]]: - """ - Scan files in a directory for potential template variables. + """Scan files in a directory for potential template variables. Args: directory: Path to the directory to scan @@ -290,8 +286,7 @@ def create_template_metadata( extends: str | None = None, dependencies: list[str] | None = None, ) -> TemplateMetadata: - """ - Create template metadata dictionary. + """Create template metadata dictionary. Args: name: Template name @@ -358,8 +353,7 @@ def create_template_metadata( def write_template_metadata(directory: Path, metadata: TemplateMetadata) -> None: - """ - Write template metadata to a file. + """Write template metadata to a file. Args: directory: Template directory @@ -377,8 +371,7 @@ def write_template_metadata(directory: Path, metadata: TemplateMetadata) -> None 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. + """Process a Markdown file to add a template header with available variables. Args: source_file: Source file path @@ -427,8 +420,7 @@ def create_template_structure( gitignore_spec: pathspec.PathSpec | None = None, additional_excludes: list[str] | None = None, ) -> None: - """ - Create template structure from source directory. + """Create template structure from source directory. Args: source_directory: Source directory containing the project @@ -664,8 +656,7 @@ def create_template_structure( def create_hook_script(hooks_dir: Path) -> None: - """ - Create a basic post-creation hook script. + """Create a basic post-creation hook script. Args: hooks_dir: Hooks directory @@ -716,8 +707,7 @@ def post_create(context): def process_kicad_project_file( source_file: Path, target_file: Path, variables: dict[str, TemplateVariable] ) -> None: - """ - Process a KiCad project file (.kicad_pro). + """Process a KiCad project file (.kicad_pro). Args: source_file: Source file path @@ -776,8 +766,7 @@ def process_kicad_project_file( def process_kicad_schematic_file(source_file: Path, target_file: Path) -> None: - """ - Process a KiCad schematic file (.kicad_sch). + """Process a KiCad schematic file (.kicad_sch). Args: source_file: Source file path @@ -813,8 +802,7 @@ def process_kicad_schematic_file(source_file: Path, target_file: Path) -> None: def process_kicad_pcb_file(source_file: Path, target_file: Path) -> None: - """ - Process a KiCad PCB file (.kicad_pcb). + """Process a KiCad PCB file (.kicad_pcb). Args: source_file: Source file path @@ -860,8 +848,7 @@ def sheet_replacer(match): 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. + """Render a template string using Jinja2 or custom Windows-compatible templating. First tries the custom Windows-compatible %{variable} syntax, then falls back to Jinja2. @@ -888,8 +875,7 @@ def render_template_string( def render_filename_custom( filename: str, variables: Mapping[str, str | int | bool] ) -> str: - """ - Render a filename using a custom Windows-compatible templating system. + """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: @@ -964,12 +950,11 @@ def replacer(match): 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 + 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): @@ -982,8 +967,7 @@ def replacer(match): def render_filename(filename: str, variables: Mapping[str, str | int | bool]) -> str: - """ - Render a filename using either Jinja2 or custom Windows-compatible templating. + """Render a filename using either Jinja2 or custom Windows-compatible templating. First tries the custom Windows-compatible %{variable} syntax, then falls back to Jinja2. @@ -1012,8 +996,7 @@ def render_filename(filename: str, variables: Mapping[str, str | int | bool]) -> def find_all_templates(config: Any) -> dict[str, TemplateMetadata]: - """ - Find all templates in all configured libraries. + """Find all templates in all configured libraries. Args: config: The configuration object @@ -1059,8 +1042,7 @@ def render_template_file( variables: Mapping[str, str | int | bool], is_binary: bool = False, ) -> bool: - """ - Render a template file to a target file. + """Render a template file to a target file. Args: source_file: Source file path @@ -1105,8 +1087,9 @@ def render_template_file( # 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_str = target_path_str.removesuffix( + ".jinja2" + ) # Remove ".jinja2" target_path = Path(target_path_str) with Path(target_path).open("w", encoding="utf-8") as f: f.write(rendered_content) @@ -1143,8 +1126,7 @@ def create_project_from_template( dry_run: bool = False, skip_hooks: bool = False, ) -> bool: - """ - Create a new project from a template. + """Create a new project from a template. Args: template_dir: Path to the template directory @@ -1200,7 +1182,7 @@ def create_project_from_template( return False # Check if we need to merge with variables from a parent template - if "extends" in metadata and metadata["extends"]: + if metadata.get("extends"): # TODO: Handle template inheritance click.echo("Warning: Template inheritance not fully implemented yet", err=True) @@ -1236,8 +1218,7 @@ def create_project_from_template( # Strip .jinja2 extension from target file if present target_filename = file - if target_filename.endswith(".jinja2"): - target_filename = target_filename[:-7] + target_filename = target_filename.removesuffix(".jinja2") # Render the filename if it contains template variables rendered_filename = render_filename(target_filename, variables) @@ -1300,8 +1281,7 @@ def run_post_create_hook( variables: Mapping[str, str | int | bool], template_metadata: dict[str, Any], ) -> None: - """ - Run a post-creation hook script. + """Run a post-creation hook script. Args: hook_script: Path to the hook script diff --git a/tests/test_config_commands.py b/tests/test_config_commands.py index f76d34d..f919029 100644 --- a/tests/test_config_commands.py +++ b/tests/test_config_commands.py @@ -1,6 +1,4 @@ -""" -Tests for KiCad Library Manager config commands. -""" +"""Tests for KiCad Library Manager config commands.""" import pytest from typer.testing import CliRunner diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index 235de4f..d7c49dd 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 diff --git a/tests/test_sync_command.py b/tests/test_sync_command.py index 1fd952e..367deb7 100644 --- a/tests/test_sync_command.py +++ b/tests/test_sync_command.py @@ -1,6 +1,4 @@ -""" -Tests for KiCad Library Manager sync command (formerly update command). -""" +"""Tests for KiCad Library Manager sync command (formerly update command).""" from pathlib import Path from unittest.mock import MagicMock, patch @@ -75,12 +73,12 @@ def mock_glob(self, pattern): mock_file = MagicMock() mock_file.name = "test.kicad_sym" return [mock_file] - elif "**/*.pretty" in pattern: + if "**/*.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): + if "*" 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}") diff --git a/tests/test_template.py b/tests/test_template.py index 9332bdb..b7330b2 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -1,6 +1,4 @@ -""" -Tests for template utilities and commands. -""" +"""Tests for template utilities and commands.""" import shutil import tempfile diff --git a/tests/test_unpin_command.py b/tests/test_unpin_command.py index 228d527..704f89d 100644 --- a/tests/test_unpin_command.py +++ b/tests/test_unpin_command.py @@ -1,6 +1,4 @@ -""" -Tests for the unpin command functionality. -""" +"""Tests for the unpin command functionality.""" from pathlib import Path from unittest.mock import patch diff --git a/tests/test_update_commands.py b/tests/test_update_commands.py index 04d4b90..16d9652 100644 --- a/tests/test_update_commands.py +++ b/tests/test_update_commands.py @@ -1,6 +1,4 @@ -""" -Tests for update command group and subcommands. -""" +"""Tests for update command group and subcommands.""" from unittest.mock import MagicMock, patch diff --git a/tests/test_update_service.py b/tests/test_update_service.py index 85bea9e..5f143c3 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -1,6 +1,4 @@ -""" -Tests for the update service functionality including installation detection and update operations. -""" +"""Tests for the update service functionality including installation detection and update operations.""" import sys from pathlib import Path, PureWindowsPath From 137e0b9a54f1031baa6c72b7956e7bf142479a57 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:24:51 +0200 Subject: [PATCH 004/105] test: make the test suite a package Resolves INP001 for every test module at once: pytest imports them as tests., so module names under tests/ cannot collide. --- tests/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/__init__.py 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.""" From 0b471764f2b6de71573ed90b5d892d4353a64f85 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:26:14 +0200 Subject: [PATCH 005/105] perf: load heavy third-party modules on first use on Python 3.15 Modules that use questionary, requests, jinja2, yaml, pathspec, or packaging.version only inside functions declare them in __lazy_modules__ (PEP 810). Python 3.15 then defers the import until first use; older versions ignore the variable. Imports stay at module top level. `python -X importtime -c 'import kicad_lib_manager.main'` on Python 3.15.0, macOS arm64, median of five warm runs: 140 ms before, 45 ms after. None of the six modules is loaded by the import; `kilm --help` and the test suite pass on 3.15. --- AGENTS.md | 4 ++++ kicad_lib_manager/commands/status/command.py | 3 +++ kicad_lib_manager/commands/template/command.py | 3 +++ kicad_lib_manager/services/config_service.py | 3 +++ kicad_lib_manager/services/library_service.py | 3 +++ kicad_lib_manager/services/update_service.py | 3 +++ kicad_lib_manager/utils/metadata.py | 3 +++ kicad_lib_manager/utils/template.py | 3 +++ 8 files changed, 25 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9342020..eb3954d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,10 @@ files; tests run in `just check`. 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. - 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/kicad_lib_manager/commands/status/command.py b/kicad_lib_manager/commands/status/command.py index d349cbd..d60dccb 100644 --- a/kicad_lib_manager/commands/status/command.py +++ b/kicad_lib_manager/commands/status/command.py @@ -1,5 +1,8 @@ """Status command implementation for KiCad Library Manager (Typer version).""" +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"yaml"} + import json from pathlib import Path from typing import Annotated diff --git a/kicad_lib_manager/commands/template/command.py b/kicad_lib_manager/commands/template/command.py index 0339957..fecd293 100644 --- a/kicad_lib_manager/commands/template/command.py +++ b/kicad_lib_manager/commands/template/command.py @@ -2,6 +2,9 @@ Provides commands for creating KiCad projects from templates and creating templates from projects. """ +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"jinja2", "pathspec", "questionary", "yaml"} + import json import os import shutil diff --git a/kicad_lib_manager/services/config_service.py b/kicad_lib_manager/services/config_service.py index 95e39cb..0cb36bc 100644 --- a/kicad_lib_manager/services/config_service.py +++ b/kicad_lib_manager/services/config_service.py @@ -1,5 +1,8 @@ """Configuration management for KiCad Library Manager.""" +# 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 diff --git a/kicad_lib_manager/services/library_service.py b/kicad_lib_manager/services/library_service.py index 19f2d06..7ab8d8f 100644 --- a/kicad_lib_manager/services/library_service.py +++ b/kicad_lib_manager/services/library_service.py @@ -2,6 +2,9 @@ Core functionality for managing KiCad libraries. """ +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"yaml"} + import os import platform import re diff --git a/kicad_lib_manager/services/update_service.py b/kicad_lib_manager/services/update_service.py index 029bbf8..8a94966 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -2,6 +2,9 @@ Handles installation method detection, PyPI integration, and update execution. """ +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"packaging.version", "requests"} + import json import os import subprocess diff --git a/kicad_lib_manager/utils/metadata.py b/kicad_lib_manager/utils/metadata.py index 2602d32..1409a63 100644 --- a/kicad_lib_manager/utils/metadata.py +++ b/kicad_lib_manager/utils/metadata.py @@ -1,5 +1,8 @@ """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 pathlib import Path diff --git a/kicad_lib_manager/utils/template.py b/kicad_lib_manager/utils/template.py index 9e69823..1bb3fff 100644 --- a/kicad_lib_manager/utils/template.py +++ b/kicad_lib_manager/utils/template.py @@ -2,6 +2,9 @@ Provides functions for working with project templates. """ +# PEP 810: on Python 3.15+ these imports load on first use. +__lazy_modules__ = {"jinja2", "pathspec", "yaml"} + import importlib.util import json import os From a80a06900d888832793e0717937bfde197d6686c Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:26:27 +0200 Subject: [PATCH 006/105] ci: test on Python 3.15 Adds 3.15 to the check matrix and classifiers. The matrix runs every registered command's --help through tests/test_leaf_command_usage.py and imports every module through tests/test_console.py, which exercises the PEP 810 lazy imports on 3.15. --- .github/workflows/ci.yml | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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/pyproject.toml b/pyproject.toml index 7735a12..16f438b 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", From 96e8cc11842732521e413302efa87ec4b50a3995 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:32:59 +0200 Subject: [PATCH 007/105] refactor(release): pass ruff ALL and strict ty in github_release - publish_release(github, publication) takes a frozen Publication instead of five keyword arguments (PLR0913). - gh JSON is parsed by parse_release and has_tag_ref, which check the shape and raise ReleaseError instead of trusting json.loads as a TypedDict (ty unsound-assignment). The asset rule (uploaded state and a sha256 digest) and the exact-tag rule for matching-refs now have tests. - GhCli resolves gh on PATH once and runs it by absolute path (S607); a missing gh is a ReleaseError instead of FileNotFoundError. - main writes the annotation and summary to stderr/stdout directly (T201). - Type-only imports under TYPE_CHECKING; test docstrings; the draft test parameter is keyword-only (FBT001). Behavior is otherwise unchanged. Read-only run of main against the real v0.5.6 release (writes replaced by failures) accepted it with no writes and rejected a foreign RELEASE_SHA. --- .github/scripts/github_release.py | 217 ++++++++++++++++++++---------- tests/test_github_release.py | 100 +++++++++++--- 2 files changed, 222 insertions(+), 95 deletions(-) diff --git a/.github/scripts/github_release.py b/.github/scripts/github_release.py index 289ac2b..260ba0c 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`." ) + 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/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) From 58d0dea6866078077bcc3ab9baa9683903bb96f6 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:34:39 +0200 Subject: [PATCH 008/105] fix: keep every backup taken within one second Backups were named with a whole-second stamp, so a second backup of the same file in that second overwrote the first and the history kept fewer versions than max_backups. Stamps now carry microseconds, and a taken name moves the stamp forward a microsecond, so name order stays creation order (pruning relies on it). Old whole-second names sort correctly among the new ones. Closes #12 --- kicad_lib_manager/utils/backup.py | 79 +++++++++++++++++++------------ tests/test_backup.py | 72 ++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 30 deletions(-) create mode 100644 tests/test_backup.py diff --git a/kicad_lib_manager/utils/backup.py b/kicad_lib_manager/utils/backup.py index c202ddc..4106ff0 100644 --- a/kicad_lib_manager/utils/backup.py +++ b/kicad_lib_manager/utils/backup.py @@ -1,51 +1,70 @@ -"""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 the +name order of a file's backups is their creation order; pruning relies on it. +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}") - - # 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}") + msg = f"File not found: {file_path}" + raise FileNotFoundError(msg) - # 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, which keeps name order equal to creation + order. """ - # 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 the oldest backups of file_path beyond the newest max_backups.""" + 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/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()) From b98f1306599d34f0b5bd00a63c4f1d093d3dd077 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:34:41 +0200 Subject: [PATCH 009/105] fix(template): print list --json without line wrapping Rich wrapped long values at the console width, inserting newlines inside JSON strings so the output did not parse. Echo the JSON as is. --- .../commands/template/command.py | 5 +- tests/test_template_commands.py | 73 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 tests/test_template_commands.py diff --git a/kicad_lib_manager/commands/template/command.py b/kicad_lib_manager/commands/template/command.py index fecd293..128afe8 100644 --- a/kicad_lib_manager/commands/template/command.py +++ b/kicad_lib_manager/commands/template/command.py @@ -998,9 +998,8 @@ def list_templates( # If JSON output is requested if json_output: - import json as json_lib - - get_console().print(json_lib.dumps(all_templates, indent=2)) + # Plain echo: a Rich console would wrap long values and break the JSON. + typer.echo(json.dumps(all_templates, indent=2)) return # Group templates by library for display diff --git a/tests/test_template_commands.py b/tests/test_template_commands.py new file mode 100644 index 0000000..88f591a --- /dev/null +++ b/tests/test_template_commands.py @@ -0,0 +1,73 @@ +"""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, +) + + +@pytest.fixture +def home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """An empty home directory, so Config reads and writes only test data.""" + home_dir = tmp_path / "home" + home_dir.mkdir() + monkeypatch.setattr(Path, "home", lambda: home_dir) + return home_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 \ No newline at end of file From 5e4e4f066fd8642911f15ec114f748b9a5d6e1a8 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:34:53 +0200 Subject: [PATCH 010/105] fix(update): detect uv only when kilm runs from a uv tool environment UV_TOOL_DIR or UV_TOOL_BIN_DIR made any install look like a uv tool, for example on CI runners and in shells that use uv for other tools. Only an interpreter inside UV_TOOL_DIR or uv's default tools directory (uv/tools, uv/data/tools on Windows) now counts. --- kicad_lib_manager/services/update_service.py | 40 +++++++------------- tests/test_update_service.py | 27 +++++++++++-- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/kicad_lib_manager/services/update_service.py b/kicad_lib_manager/services/update_service.py index 8a94966..f8725fd 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -57,33 +57,21 @@ def detect_conda(self) -> bool: ) 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") - ) + """Detect a uv tool install: the interpreter lives in a uv tools directory. - return uv_path_patterns or uv_tools_executable or uv_env_vars + 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 + tool_dir = os.environ.get("UV_TOOL_DIR") + return bool(tool_dir) and self.executable_path.is_relative_to( + type(self.executable_path)(tool_dir) + ) def detect_virtual_env(self) -> bool: """Detect virtual environment (pip in venv).""" diff --git a/tests/test_update_service.py b/tests/test_update_service.py index 5f143c3..72e5cd4 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -66,14 +66,29 @@ def test_detect_conda_installation_with_env_var(self, monkeypatch): assert detector.detect_conda() is True assert detector.detect() == "conda" - 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") + def test_detect_uv_installation_in_custom_tool_dir(self, monkeypatch): + """An executable inside UV_TOOL_DIR is a uv tool install.""" + monkeypatch.setenv("UV_TOOL_DIR", "/custom/uv-tools") - detector = InstallationDetector(executable_path=Path("/usr/bin/python")) + detector = InstallationDetector( + executable_path=Path("/custom/uv-tools/kilm/bin/python") + ) assert detector.detect_uv() is True assert detector.detect() == "uv" + def test_uv_env_vars_alone_are_not_a_uv_install(self, monkeypatch): + """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") + + detector = InstallationDetector(executable_path=Path("/usr/bin/python")) + assert detector.detect_uv() is False + assert detector.detect() != "uv" + def test_detect_uv_installation_path_patterns(self): """Test detection of UV installation via path patterns.""" detector = InstallationDetector( @@ -90,6 +105,10 @@ def test_detect_uv_installation_path_patterns(self): r"C:\Users\user\AppData\Roaming\uv\tools\kilm\Scripts\python.exe", "uv", ), + ( + r"C:\Users\user\AppData\Roaming\uv\data\tools\kilm\Scripts\python.exe", + "uv", + ), ], ) def test_detect_windows_path_patterns(self, executable: str, expected: str) -> None: From 769e92996f2442c95a99c2d5a07c55e23d9c7222 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:35:07 +0200 Subject: [PATCH 011/105] fix(update): detect conda only when kilm runs from a conda environment CONDA_DEFAULT_ENV is set whenever a conda environment is active, and many shells activate base by default, so a uv tool or pip install of kilm was reported as conda and refused automatic updates. Only an interpreter inside CONDA_PREFIX or a conda directory now counts. --- kicad_lib_manager/services/update_service.py | 13 +++++++++--- tests/test_update_service.py | 22 ++++++++++++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/kicad_lib_manager/services/update_service.py b/kicad_lib_manager/services/update_service.py index f8725fd..4991788 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -51,9 +51,16 @@ def detect_pipx(self) -> bool: 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 + """Detect a conda install: 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 + prefix = os.environ.get("CONDA_PREFIX") + return bool(prefix) and self.executable_path.is_relative_to( + type(self.executable_path)(prefix) ) def detect_uv(self) -> bool: diff --git a/tests/test_update_service.py b/tests/test_update_service.py index 72e5cd4..726a06c 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -28,6 +28,7 @@ def _clear_detection_env(self, monkeypatch: pytest.MonkeyPatch) -> None: for var in ( "PIPX_HOME", "CONDA_DEFAULT_ENV", + "CONDA_PREFIX", "UV_TOOL_DIR", "UV_TOOL_BIN_DIR", "VIRTUAL_ENV", @@ -58,14 +59,27 @@ def test_detect_conda_installation_path_pattern(self): assert detector.detect_conda() is True assert detector.detect() == "conda" - 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 test_detect_conda_installation_in_active_prefix(self, monkeypatch): + """An executable inside the active CONDA_PREFIX is a conda install.""" + monkeypatch.setenv("CONDA_PREFIX", "/opt/envs/kilm-env") - detector = InstallationDetector(executable_path=Path("/usr/bin/python")) + detector = InstallationDetector( + executable_path=Path("/opt/envs/kilm-env/bin/python") + ) assert detector.detect_conda() is True assert detector.detect() == "conda" + def test_active_conda_env_alone_is_not_a_conda_install(self, monkeypatch): + """Many shells activate conda's base env, whatever installed kilm.""" + monkeypatch.setenv("CONDA_DEFAULT_ENV", "base") + monkeypatch.setenv("CONDA_PREFIX", "/opt/envs/base") + + detector = InstallationDetector( + executable_path=Path("/Users/user/.local/share/uv/tools/kilm/bin/python") + ) + assert detector.detect_conda() is False + assert detector.detect() == "uv" + def test_detect_uv_installation_in_custom_tool_dir(self, monkeypatch): """An executable inside UV_TOOL_DIR is a uv tool install.""" monkeypatch.setenv("UV_TOOL_DIR", "/custom/uv-tools") From 3f5fafc8eb3ebd727ea69dd418a0b040bf2686ce Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:35:26 +0200 Subject: [PATCH 012/105] fix(template): honor --library in template create and list create accepted --library but ignored it, so a template that shared its name with one in an earlier library could not be used. list filtered after name clashes were resolved, so it hid the same template. Both now search only the requested library. --- .../commands/template/command.py | 49 ++++++++----------- kicad_lib_manager/utils/template.py | 10 +++- tests/test_template_commands.py | 39 ++++++++++++++- 3 files changed, 67 insertions(+), 31 deletions(-) diff --git a/kicad_lib_manager/commands/template/command.py b/kicad_lib_manager/commands/template/command.py index 128afe8..6130045 100644 --- a/kicad_lib_manager/commands/template/command.py +++ b/kicad_lib_manager/commands/template/command.py @@ -110,10 +110,11 @@ def create( config = Config() # Find all available templates - all_templates = find_all_templates(config) + all_templates = find_all_templates(config, library) if not all_templates: - get_console().print("No templates found in any configured libraries.") + where = f"library '{library}'" if library else "any configured libraries" + get_console().print(f"No templates found in {where}.") get_console().print("Use 'kilm template make' to create a template first.") return @@ -155,9 +156,9 @@ def create( get_console().print(f"Template '{template}' not found.") get_console().print("Available templates:") for t_name, t_data in all_templates.items(): - library = t_data.get("source_library", "unknown") + source = t_data.get("source_library", "unknown") description = t_data.get("description", "") - get_console().print(f" {t_name} ({library}): {description}") + get_console().print(f" {t_name} ({source}): {description}") return else: # Interactive template selection using questionary @@ -955,9 +956,22 @@ def list_templates( kilm template list --json """ config = Config() + all_templates = find_all_templates(config, library) - # Find all available templates - all_templates = find_all_templates(config) + if not all_templates and library: + get_console().print() + get_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 not all_templates: get_console().print() @@ -973,29 +987,6 @@ def list_templates( ) 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: - get_console().print() - get_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: # Plain echo: a Rich console would wrap long values and break the JSON. diff --git a/kicad_lib_manager/utils/template.py b/kicad_lib_manager/utils/template.py index 1bb3fff..8c730f4 100644 --- a/kicad_lib_manager/utils/template.py +++ b/kicad_lib_manager/utils/template.py @@ -998,11 +998,14 @@ def render_filename(filename: str, variables: Mapping[str, str | int | bool]) -> return filename -def find_all_templates(config: Any) -> dict[str, TemplateMetadata]: +def find_all_templates( + config: Any, library: str | None = None +) -> dict[str, TemplateMetadata]: """Find all templates in all configured libraries. Args: config: The configuration object + library: Only search the library with this name (case-insensitive) Returns: Dictionary mapping template names to template metadata @@ -1010,6 +1013,11 @@ def find_all_templates(config: Any) -> dict[str, TemplateMetadata]: all_templates: dict[str, TemplateMetadata] = {} all_libraries = config.get_libraries() # type: ignore[attr-defined] + if library is not None: + all_libraries = [ + lib for lib in all_libraries if lib.get("name", "").lower() == library.lower() + ] + for library in all_libraries: library_path = library.get("path") if not library_path or not Path(library_path).exists(): diff --git a/tests/test_template_commands.py b/tests/test_template_commands.py index 88f591a..ab0a884 100644 --- a/tests/test_template_commands.py +++ b/tests/test_template_commands.py @@ -70,4 +70,41 @@ def test_list_json_is_valid_json_for_long_values(home: Path) -> None: templates = json.loads(invoke("list", "--json")) - assert templates["board"]["description"] == "x" * 150 \ No newline at end of file + assert templates["board"]["description"] == "x" * 150 + + +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 that library's template even when an earlier one shares its 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" From 4ff58bbc9b190bccc71e3f0768c37b0d589619ed Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:35:40 +0200 Subject: [PATCH 013/105] fix(template): render %{...} variable defaults before prompting template make writes defaults such as directory_name and project_filename in the %{...} filename syntax, but template create rendered only {{ ... }} defaults. Accepting the offered default stored the raw expression, so files named %{project_filename} were created as '%{project_name}.kicad_pro' and content saw the unrendered text. --- .../commands/template/command.py | 6 +++- tests/test_template_commands.py | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/kicad_lib_manager/commands/template/command.py b/kicad_lib_manager/commands/template/command.py index 6130045..efd0106 100644 --- a/kicad_lib_manager/commands/template/command.py +++ b/kicad_lib_manager/commands/template/command.py @@ -25,6 +25,7 @@ from ...services.config_service import Config from ...utils.template import ( + FILENAME_VAR_PATTERN, HOOKS_DIR, POST_CREATE_HOOK, TEMPLATE_CONTENT_DIR, @@ -275,7 +276,10 @@ def create( # Render the default value using already known variables rendered_default = default - if default and "{{" in default and "}}" in default: + if default and ( + ("{{" in default and "}}" in default) + or FILENAME_VAR_PATTERN.search(default) + ): try: # Use the 'variables' dict which now contains previously entered answers rendered_default = render_template_string(default, variables) diff --git a/tests/test_template_commands.py b/tests/test_template_commands.py index ab0a884..ae930b3 100644 --- a/tests/test_template_commands.py +++ b/tests/test_template_commands.py @@ -108,3 +108,38 @@ def test_list_filters_library_before_resolving_name_clashes(home: Path) -> None: 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()[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" From 8e59a479185853fc0b084bc4b7f0d75239e478bc Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:35:40 +0200 Subject: [PATCH 014/105] fix: replace kicad_common.json and library tables atomically update_kicad_env_vars, update_pinned_libraries, and write_file_with_encoding (used for the library tables) truncated the file and then wrote it, so a failure part way (a full disk, or a library name carrying an undecodable byte as a lone surrogate) left KiCad's configuration empty or half-written. They now encode first and replace the file with write_atomically. --- kicad_lib_manager/utils/env_vars.py | 8 +++--- kicad_lib_manager/utils/file_ops.py | 9 ++++-- tests/test_env_vars.py | 43 ++++++++++++++++++++++++++++- tests/test_file_ops.py | 33 ++++++++++++++++++++++ 4 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 tests/test_file_ops.py diff --git a/kicad_lib_manager/utils/env_vars.py b/kicad_lib_manager/utils/env_vars.py index e5cebc3..4b7c66d 100644 --- a/kicad_lib_manager/utils/env_vars.py +++ b/kicad_lib_manager/utils/env_vars.py @@ -7,6 +7,8 @@ import subprocess from pathlib import Path +from kicad_lib_manager.utils.file_ops import write_atomically + # Import Config here, but only use it when needed to avoid circular imports try: from ..services.config_service import Config @@ -216,8 +218,7 @@ def update_kicad_env_vars( # 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) + write_atomically(kicad_common, json.dumps(config, indent=2).encode("utf-8")) return changes_needed @@ -307,7 +308,6 @@ def update_pinned_libraries( # 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) + write_atomically(kicad_common, json.dumps(config, indent=2).encode("utf-8")) return changes_needed diff --git a/kicad_lib_manager/utils/file_ops.py b/kicad_lib_manager/utils/file_ops.py index a9ee97d..484f664 100644 --- a/kicad_lib_manager/utils/file_ops.py +++ b/kicad_lib_manager/utils/file_ops.py @@ -40,15 +40,18 @@ def read_file_with_encoding(file_path: Path, encodings: list[str] | None = None) 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. + + 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. Args: file_path: Path to the file to write content: Content to write encoding: Encoding to use (defaults to utf-8) """ - 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: diff --git a/tests/test_env_vars.py b/tests/test_env_vars.py index 10a5753..14e988b 100644 --- a/tests/test_env_vars.py +++ b/tests/test_env_vars.py @@ -1,9 +1,15 @@ +"""Tests for KiCad environment variables and pinned libraries.""" + import json +from collections.abc import Callable, Iterator from pathlib import Path import pytest -from kicad_lib_manager.utils.env_vars import update_kicad_env_vars +from kicad_lib_manager.utils.env_vars import ( + update_kicad_env_vars, + update_pinned_libraries, +) def test_update_kicad_env_vars(tmp_path): @@ -97,3 +103,38 @@ def test_update_kicad_env_vars(tmp_path): with pytest.raises(ValueError): update_kicad_env_vars(kicad_config, env_vars) + + +class _EncoderFailure(OSError): + """Stands in for a disk error in the middle of writing JSON.""" + + +def _failing_iterencode(*_args: object, **_kwargs: object) -> Iterator[str]: + """Yield the start of a JSON document, then fail like a full disk.""" + yield '{"environment": ' + raise _EncoderFailure + + +@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") + monkeypatch.setattr(json.JSONEncoder, "iterencode", _failing_iterencode) + + with pytest.raises(_EncoderFailure): + update(tmp_path) + + assert kicad_common.read_text(encoding="utf-8") == original 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" From 439c33aba0d8ef692ef2722533b4055ab2810189 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:35:50 +0200 Subject: [PATCH 015/105] fix(template): keep the template description in interactive make The prompt for a detected variable's description reused the name of the template description, so the last answer became the template's description in metadata.yaml. --- .../commands/template/command.py | 4 +-- tests/test_template_commands.py | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/kicad_lib_manager/commands/template/command.py b/kicad_lib_manager/commands/template/command.py index efd0106..b398b26 100644 --- a/kicad_lib_manager/commands/template/command.py +++ b/kicad_lib_manager/commands/template/command.py @@ -737,12 +737,12 @@ def make( ): # Use the first value as default default_value = values[0] if values else "" - description = typer.prompt( + var_description = typer.prompt( " Description", default=f"Value for {var_name}" ) detected_variables[var_name] = { - "description": description, + "description": var_description, "default": default_value, } diff --git a/tests/test_template_commands.py b/tests/test_template_commands.py index ae930b3..be62d24 100644 --- a/tests/test_template_commands.py +++ b/tests/test_template_commands.py @@ -143,3 +143,31 @@ def ask(self) -> str: 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" From 1da0755f3ce181153874795e5a2333469ac78b50 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:36:07 +0200 Subject: [PATCH 016/105] fix(update): report Nix and unrecognized installs instead of assuming pip detect_installation_method returns an InstallMethod Literal. Interpreters in the Nix store are reported as nix with flake update and rebuild guidance; pip is reported only when kilm's INSTALLER record says pip, and anything else is unknown. Neither is offered pip, which cannot update a read-only store path or a distribution package. --- kicad_lib_manager/commands/update/docs.mdx | 6 +- kicad_lib_manager/commands/update/perform.py | 2 +- kicad_lib_manager/services/update_service.py | 64 +++++++++++++++----- tests/test_update_service.py | 35 ++++++++++- 4 files changed, 88 insertions(+), 19 deletions(-) diff --git a/kicad_lib_manager/commands/update/docs.mdx b/kicad_lib_manager/commands/update/docs.mdx index 0488de3..6bebb2e 100644 --- a/kicad_lib_manager/commands/update/docs.mdx +++ b/kicad_lib_manager/commands/update/docs.mdx @@ -57,8 +57,12 @@ Every subcommand also accepts `--help`. | **uv** | Yes | `uv tool upgrade kilm` | | **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 | -For methods without automatic updates, `kilm update perform` prints the manual command and exits with status `1`. +KiLM reports **unknown** when nothing identifies the installer, for example a distribution package, rather than guessing pip. + +For methods without automatic updates, `kilm update perform` prints the manual instruction and exits with status `1`. ## Examples diff --git a/kicad_lib_manager/commands/update/perform.py b/kicad_lib_manager/commands/update/perform.py index 1f26ae2..70e25d9 100644 --- a/kicad_lib_manager/commands/update/perform.py +++ b/kicad_lib_manager/commands/update/perform.py @@ -71,7 +71,7 @@ def perform_update_command( update_cmd = update_service.get_update_instructions() get_console().print(f"Installation method: [bold]{method}[/bold]") - get_console().print(f"Please run manually: [bold cyan]{update_cmd}[/bold cyan]") + get_console().print(f"Update manually: [bold cyan]{update_cmd}[/bold cyan]") raise typer.Exit(1) # Perform update check first (unless forced) diff --git a/kicad_lib_manager/services/update_service.py b/kicad_lib_manager/services/update_service.py index 4991788..03f48eb 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -5,18 +5,34 @@ # PEP 810: on Python 3.15+ these imports load on first use. __lazy_modules__ = {"packaging.version", "requests"} +import importlib.metadata import json import os import subprocess import sys import time from pathlib import Path, PurePath -from typing import Any +from typing import Any, Literal import requests from packaging.version import InvalidVersion, Version +InstallMethod = Literal[ + "pipx", "conda", "uv", "nix", "pip-venv", "homebrew", "pip", "unknown" +] +"""How KiLM was installed; decides how it can be updated.""" + + +def read_installer() -> str | None: + """Return the installer recorded for kilm (INSTALLER file), if any.""" + try: + installer = importlib.metadata.distribution("kilm").read_text("INSTALLER") + except importlib.metadata.PackageNotFoundError: + return None + return installer.strip() if installer else None + + class InstallationDetector: """Platform-aware installation method detector.""" @@ -24,9 +40,13 @@ def __init__( self, executable_path: PurePath | None = None, platform_name: str | None = None, + *, + installer: str | None = "", ): self.executable_path = executable_path or Path(sys.executable) self.platform_name = platform_name or os.name + # "" means "read it from the installed distribution". + self.installer = read_installer() if installer == "" else installer # 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() @@ -98,9 +118,15 @@ def detect_homebrew(self) -> bool: 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' + def detect_nix(self) -> bool: + """Detect a Nix install: the interpreter lives in the Nix store.""" + return self.executable_str.startswith("/nix/store/") + + def detect(self) -> InstallMethod: + """Detect 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. """ # Priority order matters - more specific first if self.detect_pipx(): @@ -112,20 +138,23 @@ def detect(self) -> str: if self.detect_uv(): return "uv" + if self.detect_nix(): + return "nix" + if self.detect_virtual_env(): return "pip-venv" if self.detect_homebrew(): return "homebrew" - return "pip" + if self.installer == "pip": + return "pip" + + return "unknown" -# 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' - """ +def detect_installation_method() -> InstallMethod: + """Detect how KiLM was installed to determine appropriate update strategy.""" detector = InstallationDetector() return detector.detect() @@ -243,7 +272,7 @@ class UpdateManager: def __init__(self, current_version: str): self.version_checker = PyPIVersionChecker("kilm") self.current_version = current_version - self.installation_method = detect_installation_method() + self.installation_method: InstallMethod = detect_installation_method() def check_latest_version(self) -> str | None: """Check for latest version available on PyPI.""" @@ -260,15 +289,18 @@ def is_newer_version_available(self, latest_version: str) -> bool: def get_update_instruction(self) -> str: """Get update instruction for the detected installation method.""" - instructions = { + instructions: dict[InstallMethod, str] = { "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)", + "nix": "nix flake update, then rebuild the configuration or profile" + " that installs kilm", + "unknown": "Update KiLM with the tool that installed it", } - return instructions.get(self.installation_method, "Check your package manager") + return instructions[self.installation_method] def can_auto_update(self) -> bool: """Check if automatic update is possible for this installation method.""" @@ -280,7 +312,7 @@ def perform_update(self) -> tuple[bool, str]: """ if not self.can_auto_update(): instruction = self.get_update_instruction() - return False, f"Manual update required. Run: {instruction}" + return False, f"Manual update required: {instruction}" update_functions = { "pipx": update_via_pipx, @@ -387,7 +419,7 @@ def show_update_notification( message_lines.extend( [ f"Installation method: [bold]{method}[/bold] (manual update required)", - f"Run: [bold cyan]{update_cmd}[/bold cyan]", + f"Update manually: [bold cyan]{update_cmd}[/bold cyan]", ] ) @@ -407,7 +439,7 @@ 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: + def get_installation_method(self) -> InstallMethod: """Get detected installation method.""" return self.manager.installation_method diff --git a/tests/test_update_service.py b/tests/test_update_service.py index 726a06c..b97f5d0 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -154,6 +154,33 @@ def test_detect_pip_venv_installation_prefix_check(self, monkeypatch): assert detector.detect_virtual_env() is True assert detector.detect() == "pip-venv" + def test_detect_nix_installation(self, monkeypatch): + """Interpreters in the Nix store are Nix installs, even inside a venv.""" + monkeypatch.setenv("VIRTUAL_ENV", "/home/user/project/.venv") + + detector = InstallationDetector( + executable_path=Path("/nix/store/abc123-python3-3.12.9/bin/python3.12") + ) + assert detector.detect() == "nix" + + def test_system_python_installed_by_pip(self, monkeypatch): + """Outside any environment, pip's INSTALLER record identifies pip.""" + monkeypatch.setattr(sys, "prefix", sys.base_prefix) + + detector = InstallationDetector( + executable_path=Path("/usr/bin/python3"), installer="pip" + ) + assert detector.detect() == "pip" + + def test_unrecognized_installation_is_unknown(self, monkeypatch): + """Without evidence of an installer, detection does not guess pip.""" + monkeypatch.setattr(sys, "prefix", sys.base_prefix) + + detector = InstallationDetector( + executable_path=Path("/usr/bin/python3"), installer=None + ) + assert detector.detect() == "unknown" + def test_platform_detection(self): """Test platform detection methods.""" # Test Windows detection @@ -223,7 +250,7 @@ def test_can_auto_update_supported_methods(self, monkeypatch): def test_can_auto_update_unsupported_methods(self, monkeypatch): """Test auto-update support for unsupported installation methods.""" - for method in ["conda", "homebrew"]: + for method in ["conda", "homebrew", "nix", "unknown"]: with patch( "kicad_lib_manager.services.update_service.detect_installation_method" ) as mock_detect: @@ -240,6 +267,12 @@ def test_get_update_instructions(self): ("uv", "uv tool upgrade kilm"), ("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"), ] for method, expected in test_cases: From 7614f403792f623d0e14c9523e4d70634ae5764d Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:36:17 +0200 Subject: [PATCH 017/105] fix(template): template the main KiCad files in template make The main project, schematic, and PCB were recorded as strings and compared with Path objects, which never matched. They were copied unchanged, so projects created from the template kept the source project's file names and references instead of project_filename. --- kicad_lib_manager/utils/template.py | 12 ++++++------ tests/test_template.py | 30 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/kicad_lib_manager/utils/template.py b/kicad_lib_manager/utils/template.py index 8c730f4..8ff28d1 100644 --- a/kicad_lib_manager/utils/template.py +++ b/kicad_lib_manager/utils/template.py @@ -480,9 +480,9 @@ def create_template_structure( 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 + main_project_file: Path | None = None + main_schematic_file: Path | None = None + main_pcb_file: Path | None = None # First scan to find main KiCad files click.echo("Scanning for main KiCad files...") @@ -515,17 +515,17 @@ def create_template_structure( 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) + main_project_file = 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) + main_schematic_file = 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) + main_pcb_file = Path(root) / file_name click.echo(f"Found main PCB file: {file_name}") # Copy files from source to template diff --git a/tests/test_template.py b/tests/test_template.py index b7330b2..e4dd7aa 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -1,5 +1,6 @@ """Tests for template utilities and commands.""" +import json import shutil import tempfile from pathlib import Path @@ -16,6 +17,7 @@ TEMPLATES_DIR, create_project_from_template, create_template_metadata, + create_template_structure, find_all_templates, render_filename, render_filename_custom, @@ -332,5 +334,33 @@ def test_find_all_templates_keeps_first_duplicate(tmp_path: Path) -> None: assert board["library_path"] == str(tmp_path / "first") +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"}}') + (source / "Board.kicad_sch").write_text('(kicad_sch (project "Board"))') + (source / "Board.kicad_pcb").write_text('(kicad_pcb (sheetfile "Board.kicad_sch"))') + template_dir = tmp_path / "template" + create_template_structure(source, template_dir, create_template_metadata("tpl")) + project = tmp_path / "project" + + create_project_from_template( + template_dir, + 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" + assert '(project "Widget")' in (project / "Widget.kicad_sch").read_text() + assert '(sheetfile "Widget.kicad_sch")' in (project / "Widget.kicad_pcb").read_text() + if __name__ == "__main__": pytest.main() From db71ec32218f292e06b734919a41a91dfa70562c Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:36:29 +0200 Subject: [PATCH 018/105] fix(template): stop HTML-escaping variables in rendered files select_autoescape escapes templates built from strings by default, so a project named 'R&D' became 'R&D' in KiCad files and Markdown. The templates produce KiCad and text files, never HTML. --- kicad_lib_manager/utils/template.py | 4 ++-- tests/test_template.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/kicad_lib_manager/utils/template.py b/kicad_lib_manager/utils/template.py index 8ff28d1..ed733ca 100644 --- a/kicad_lib_manager/utils/template.py +++ b/kicad_lib_manager/utils/template.py @@ -1084,10 +1084,10 @@ def render_template_file( # If it's a Jinja template, process it if source_file.name.endswith(".jinja2"): - # Create Jinja2 template environment with safe auto-escaping + # No HTML escaping: templates render KiCad and text files. env = jinja2.Environment( loader=jinja2.FileSystemLoader(source_file.parent), - autoescape=jinja2.select_autoescape(["html", "xml"]), + autoescape=False, undefined=jinja2.StrictUndefined, ) diff --git a/tests/test_template.py b/tests/test_template.py index e4dd7aa..b397d77 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -362,5 +362,15 @@ def test_main_kicad_files_are_named_after_the_new_project(tmp_path: Path) -> Non assert '(project "Widget")' in (project / "Widget.kicad_sch").read_text() assert '(sheetfile "Widget.kicad_sch")' in (project / "Widget.kicad_pcb").read_text() +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 }}") + target = tmp_path / "title.txt" + + render_template_file(source, target, {"project_name": "R&D <1>"}) + + assert target.read_text() == "R&D <1>" + if __name__ == "__main__": pytest.main() From 411a88fd3d838a743077fe2a078dd4fed21779c5 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:37:02 +0200 Subject: [PATCH 019/105] fix(setup): repair wrapped library URIs and write the tables atomically fix_invalid_uris replaced `(uri "${/path}/lib")` with the literal text `(uri "\1/\2")` because the raw replacement string doubled its backslashes, so every repaired library lost its path. It also rewrote the table in place, which leaves an empty table when the write fails part way. Use real group references and utils.file_ops.write_atomically. --- kicad_lib_manager/commands/setup/command.py | 9 +- tests/test_setup_command.py | 95 +++++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 tests/test_setup_command.py diff --git a/kicad_lib_manager/commands/setup/command.py b/kicad_lib_manager/commands/setup/command.py index 99237ef..63a9779 100644 --- a/kicad_lib_manager/commands/setup/command.py +++ b/kicad_lib_manager/commands/setup/command.py @@ -18,7 +18,7 @@ update_kicad_env_vars, update_pinned_libraries, ) -from ...utils.file_ops import list_libraries +from ...utils.file_ops import list_libraries, write_atomically from ...utils.metadata import read_cloud_metadata, read_github_metadata @@ -63,11 +63,8 @@ def fix_invalid_uris( 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) + fixed_content = re.sub(pattern, r'(uri "\1/\2")', content) + write_atomically(table_path, fixed_content.encode("utf-8")) return changes_made diff --git a/tests/test_setup_command.py b/tests/test_setup_command.py new file mode 100644 index 0000000..e5a40dc --- /dev/null +++ b/tests/test_setup_command.py @@ -0,0 +1,95 @@ +"""Tests for ``kilm setup`` against a temporary KiCad configuration.""" + +import io +from collections.abc import Callable +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from kicad_lib_manager.commands.setup import command as setup_command +from kicad_lib_manager.main import app +from kicad_lib_manager.services.library_service import LibraryService + +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.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.setattr( + setup_command, "find_environment_variables", 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 _fail_writes(monkeypatch: pytest.MonkeyPatch) -> None: + """Make every file opened for writing fail after it has been opened. + + Opening with ``w`` truncates the file first, so a writer that rewrites the + target in place loses its content, while an atomic writer loses only its + temporary file. + """ + real_open: Callable[..., io.IOBase] = io.open + + def failing_open(file: object, mode: str = "r", *args: object, **kwargs: object): + handle = real_open(file, mode, *args, **kwargs) + if "w" in mode: + handle.close() + raise OSError(28, "No space left on device") + return handle + + monkeypatch.setattr(io, "open", failing_open) + + +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_command.fix_invalid_uris(kicad_config, backup_first=False) + + 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_command.fix_invalid_uris(kicad_config, backup_first=False) + + assert table.read_text(encoding="utf-8") == INVALID_URI_TABLE From f94b9021cac13d0c1a6c2984a482cd6df428f0a3 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:37:10 +0200 Subject: [PATCH 020/105] fix(setup): back up the library tables before adding libraries setup created the sym-lib-table and fp-lib-table backups after LibraryService.add_libraries had already written the new entries, so the backup held the changed table and the original was lost. Plan the change with a dry run, back up, then apply it. --- kicad_lib_manager/commands/setup/command.py | 14 +++++++++++--- tests/test_setup_command.py | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/kicad_lib_manager/commands/setup/command.py b/kicad_lib_manager/commands/setup/command.py index 63a9779..d3980d5 100644 --- a/kicad_lib_manager/commands/setup/command.py +++ b/kicad_lib_manager/commands/setup/command.py @@ -477,16 +477,16 @@ def setup( 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 + # Plan first: the tables are backed up only when they will change, and + # the backup must hold the content from before the change. 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, + dry_run=True, ) - # 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" @@ -503,6 +503,14 @@ def setup( "[blue]Created backup of footprint library table[/blue]" ) + 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=False, + ) + if added_libraries: if dry_run: get_console().print( diff --git a/tests/test_setup_command.py b/tests/test_setup_command.py index e5a40dc..5979600 100644 --- a/tests/test_setup_command.py +++ b/tests/test_setup_command.py @@ -71,6 +71,22 @@ def failing_open(file: object, mode: str = "r", *args: object, **kwargs: object) monkeypatch.setattr(io, "open", failing_open) +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" From 1e75053a5ce2ab0f73aafc7588f0ea7f9df634e6 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:37:17 +0200 Subject: [PATCH 021/105] fix(unpin): write kicad_common.json atomically unpin rewrote KiCad's kicad_common.json in place, so a failed write left an empty file and KiCad lost every setting in it. Write through utils.file_ops.write_atomically. The unpin tests now run against a kicad_common.json in tmp_path instead of patching builtins.open and Path.exists globally, and they assert the resulting file rather than accepting exit code 0 or 1. --- kicad_lib_manager/commands/unpin/command.py | 4 +- tests/test_unpin_command.py | 209 +++++++++++--------- 2 files changed, 118 insertions(+), 95 deletions(-) diff --git a/kicad_lib_manager/commands/unpin/command.py b/kicad_lib_manager/commands/unpin/command.py index d665d08..853de3f 100644 --- a/kicad_lib_manager/commands/unpin/command.py +++ b/kicad_lib_manager/commands/unpin/command.py @@ -9,6 +9,7 @@ from ...services.library_service import LibraryService from ...utils.backup import create_backup +from ...utils.file_ops import write_atomically def unpin( @@ -154,8 +155,7 @@ def unpin( # Create backup before making changes create_backup(kicad_common, max_backups) - with kicad_common.open("w") as f: - json.dump(config, f, indent=2) + write_atomically(kicad_common, json.dumps(config, indent=2).encode()) if changes_needed: if dry_run: diff --git a/tests/test_unpin_command.py b/tests/test_unpin_command.py index 704f89d..13be5b1 100644 --- a/tests/test_unpin_command.py +++ b/tests/test_unpin_command.py @@ -1,98 +1,121 @@ -"""Tests for the unpin command functionality.""" +"""Tests for ``kilm unpin`` against a temporary KiCad configuration.""" +import io +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 + +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) -> dict[str, object]: + data = json.loads(path.read_text(encoding="utf-8")) + assert isinstance(data, dict) + session = data["session"] + assert isinstance(session, dict) + 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") + real_open = io.open + + def failing_open(file: object, mode: str = "r", *args: object, **kwargs: object): + handle = real_open(file, mode, *args, **kwargs) + if "w" in mode: + handle.close() + raise OSError(28, "No space left on device") + return handle + + monkeypatch.setattr(io, "open", failing_open) + + 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 From 17d07cab84ad334ba40fbee1ffc436385df46686 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:37:40 +0200 Subject: [PATCH 022/105] fix(sync): run setup with its real defaults after --auto-setup `kilm sync --auto-setup` calls the setup function directly. setup declared its options as `param = typer.Option(...)` defaults, so the direct call received OptionInfo objects instead of None and False and failed with "'OptionInfo' object has no attribute 'split'". Declare the options with Annotated so the Python defaults are the CLI defaults. The sync tests now pull from real git repositories in tmp_path instead of patching Path methods and subprocess.run globally. --- kicad_lib_manager/commands/setup/command.py | 97 +++--- tests/test_sync_command.py | 311 ++++++-------------- 2 files changed, 154 insertions(+), 254 deletions(-) diff --git a/kicad_lib_manager/commands/setup/command.py b/kicad_lib_manager/commands/setup/command.py index d3980d5..5f1523a 100644 --- a/kicad_lib_manager/commands/setup/command.py +++ b/kicad_lib_manager/commands/setup/command.py @@ -2,6 +2,7 @@ import re from pathlib import Path +from typing import Annotated import typer from rich.panel import Panel @@ -70,47 +71,61 @@ def fix_invalid_uris( 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" - ), + 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)", + ), + ] = None, + kicad_3d_dir: Annotated[ + str | None, + typer.Option( + "--kicad-3d-dir", + envvar="KICAD_3D_LIB", + 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") + ] = 5, + 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 diff --git a/tests/test_sync_command.py b/tests/test_sync_command.py index 367deb7..3575c2b 100644 --- a/tests/test_sync_command.py +++ b/tests/test_sync_command.py @@ -1,257 +1,142 @@ -"""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.sync.command import check_for_library_changes -from kicad_lib_manager.main import app as main +from kicad_lib_manager.commands.setup import command as setup_command +from kicad_lib_manager.commands.sync import command as sync_command +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() - # Create different results for different calls - def mock_run_side_effect(*args, **kwargs): - result = MagicMock() - result.returncode = 0 +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) - # 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" - return result +class FakeConfig: + """Stands in for ``Config`` with a fixed list of GitHub libraries.""" - run_mock.side_effect = mock_run_side_effect - monkeypatch.setattr("subprocess.run", run_mock) - return run_mock + def __init__(self, libraries: list[LibraryDict]) -> None: + """Serve ``libraries`` from ``get_libraries``.""" + self.libraries = libraries + 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] -@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] - if "**/*.pretty" in pattern: - mock_dir = MagicMock() - mock_dir.name = "test.pretty" - mock_dir.is_dir.return_value = True - return [mock_dir] - if "*" 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"]) - assert result.exit_code == 0 - assert "Syncing 1 KiCad GitHub libraries" in result.output - assert "Updated" in result.output - assert "1 libraries synced" in result.output +@pytest.fixture(autouse=True) +def isolated_git(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep git and KiLM away from the user's configuration.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + 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") - # 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 +@pytest.fixture +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 - # 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 +@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 -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_reports_an_up_to_date_library(library: Path) -> None: + """A pull that brings nothing counts as up to date.""" + result = CliRunner().invoke(app, ["sync"]) - assert result.exit_code == 0 - assert "Running 'kilm setup'" in result.output + assert result.exit_code == 0, result.output + assert "Syncing 1 KiCad GitHub libraries" 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 that setup was called - mock_setup.assert_called_once() +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") -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 + result = CliRunner().invoke(app, ["sync", "--dry-run"]) - with patch("subprocess.run", mock_run): - runner = CliRunner() - result = runner.invoke(main, ["sync"]) + assert result.exit_code == 0, result.output + assert "Dry run: would execute 'git pull'" in result.output + assert not (library / "symbols").exists() - 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 +def test_sync_auto_setup_runs_setup_with_its_defaults( + upstream: Path, library: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Setup runs with its option defaults, so it looks up the library itself.""" + monkeypatch.setattr(setup_command, "find_environment_variables", lambda _name: None) + _commit_file(upstream, "symbols/New.kicad_sym", "add symbols") -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"]) + result = CliRunner().invoke(app, ["sync", "--auto-setup"]) - assert result.exit_code == 0 - assert "Success:" in result.output + assert "Running 'kilm setup'" in result.output + assert "KICAD_USER_LIB not set and not provided" 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"]) +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) - assert result.exit_code == 0 - assert "Dry run: would execute 'git pull'" in result.output + result = CliRunner().invoke(app, ["sync"]) - # Verify subprocess was not called for git pull - mock_subprocess_run.assert_not_called() + 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 - ) +def test_sync_without_libraries(monkeypatch: pytest.MonkeyPatch) -> None: + """No configured GitHub library is not an error.""" + monkeypatch.setattr(sync_command, "Config", lambda: FakeConfig([])) - runner = CliRunner() - result = runner.invoke(main, ["sync"]) + 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 From 8ad10d44c85de6cf21201610808685a4d2c41d0b Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:37:50 +0200 Subject: [PATCH 023/105] refactor: type library metadata with TypedDicts instead of Any read/write/get_default for kilm.yaml and .kilm_metadata now use GitHubMetadata and CloudMetadata (all fields optional, as in the files). Reading checks the known fields: one with the wrong type reads as absent, unknown fields are kept so a read-change-write cycle preserves them. Files are read and written as UTF-8 (KiLM writes them as ASCII, so existing files read the same). Read and write errors go to stderr instead of stdout, and only the expected I/O and parse errors are caught. generate_env_var_name no longer raises IndexError for an empty name without prefix; its 50-character limit is MAX_ENV_VAR_NAME_LENGTH. --- kicad_lib_manager/utils/constants.py | 4 + kicad_lib_manager/utils/metadata.py | 317 ++++++++++++++++----------- tests/test_metadata.py | 66 ++++++ 3 files changed, 261 insertions(+), 126 deletions(-) create mode 100644 tests/test_metadata.py diff --git a/kicad_lib_manager/utils/constants.py b/kicad_lib_manager/utils/constants.py index a38f018..c516482 100644 --- a/kicad_lib_manager/utils/constants.py +++ b/kicad_lib_manager/utils/constants.py @@ -14,6 +14,10 @@ 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/metadata.py b/kicad_lib_manager/utils/metadata.py index 1409a63..0dac759 100644 --- a/kicad_lib_manager/utils/metadata.py +++ b/kicad_lib_manager/utils/metadata.py @@ -1,208 +1,273 @@ -"""Metadata management utilities for KiCad Library Manager.""" +"""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. +Read and write failures are reported on stderr and returned as None or False, +because every caller treats unreadable metadata as missing. +""" # 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 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, +) +__all__ = [ + "CLOUD_METADATA_FILE", + "GITHUB_METADATA_FILE", + "CloudMetadata", + "GitHubMetadata", + "LibraryCapabilities", + "generate_env_var_name", + "get_default_cloud_metadata", + "get_default_github_metadata", + "read_cloud_metadata", + "read_github_metadata", + "write_cloud_metadata", + "write_github_metadata", +] -def read_github_metadata( - directory: Path, -) -> dict[str, Any] | None: - """Read metadata from a GitHub library directory. +_MODEL_SUFFIXES = (".step", ".stp", ".wrl", ".wings") - Args: - directory: Path to the GitHub library directory - Returns: - Dictionary of metadata or None if not found +class LibraryCapabilities(TypedDict, total=False): + """Which kinds of content a library has, by its top-level directories.""" + + symbols: bool + footprints: bool + templates: bool + + +class GitHubMetadata(TypedDict, total=False): + """kilm.yaml of a symbol/footprint library. 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. """ - 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) + name: str + description: str + type: str + version: str + env_var: str | None + capabilities: LibraryCapabilities + created_with: str + updated_with: str - if not isinstance(metadata, dict): - return {} - return metadata - except Exception as e: - print(f"Error reading metadata file: {e}") - return None +class CloudMetadata(TypedDict, total=False): + """.kilm_metadata of a 3D model library. Every field may be absent.""" + name: str + description: str + type: str + version: str + env_var: str | None + model_count: int + created_with: str + updated_with: str -def write_github_metadata(directory: Path, metadata: dict[str, Any]) -> bool: - """Write metadata to a GitHub library directory. - Args: - directory: Path to the GitHub library directory - metadata: Dictionary of metadata to write +FieldCheck = Callable[[object], bool] - Returns: - True if successful, False otherwise - """ - metadata_file = directory / GITHUB_METADATA_FILE - 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}") +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 LibraryCapabilities.__annotations__ + ) + + +_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 _valid_fields( + document: object, fields: Mapping[str, FieldCheck] +) -> dict[str, object]: + """The string-keyed entries of document, minus known fields of the wrong type. + + A document that is not a mapping counts as empty. + """ + if not isinstance(document, dict): + return {} + entries = cast("dict[object, object]", document) + return { + key: value + for key, value in entries.items() + if isinstance(key, str) and fields.get(key, _is_anything)(value) + } + + +def _is_anything(_value: object) -> bool: + return True -def read_cloud_metadata(directory: Path) -> dict[str, str | int | None] | None: - """Read metadata from a cloud 3D model directory. +def _report(message: str) -> None: + get_console(stderr=True).print(message, markup=False, highlight=False) - Args: - directory: Path to the cloud 3D model directory + +def read_github_metadata(directory: Path) -> GitHubMetadata | None: + """Read directory's kilm.yaml. Returns: - Dictionary of metadata or None if not found + The metadata (empty when the file holds no mapping), or None when the + file is missing or unreadable. """ - metadata_file = directory / CLOUD_METADATA_FILE + metadata_file = directory / GITHUB_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 = yaml.safe_load(f) + except (OSError, ValueError, yaml.YAMLError) as e: + _report(f"Error reading metadata file: {e}") return None + # The known fields were checked against GitHubMetadata just above. + return cast("GitHubMetadata", _valid_fields(document, _GITHUB_FIELDS)) + +def write_github_metadata(directory: Path, metadata: GitHubMetadata) -> bool: + """Write metadata to directory's kilm.yaml; return whether it succeeded.""" + metadata_file = directory / GITHUB_METADATA_FILE + try: + with metadata_file.open("w", encoding="utf-8") as f: + yaml.dump(metadata, f, default_flow_style=False) + except (OSError, yaml.YAMLError) as e: + _report(f"Error writing metadata file: {e}") + return False + return True -def write_cloud_metadata( - directory: Path, metadata: dict[str, str | int | 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 +def read_cloud_metadata(directory: Path) -> CloudMetadata | None: + """Read directory's .kilm_metadata. Returns: - True if successful, False otherwise + The metadata (empty when the file 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(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 + # The known fields were checked against CloudMetadata just above. + return cast("CloudMetadata", _valid_fields(document, _CLOUD_FIELDS)) + +def write_cloud_metadata(directory: Path, metadata: CloudMetadata) -> bool: + """Write metadata to directory's .kilm_metadata; return whether it succeeded.""" + metadata_file = directory / CLOUD_METADATA_FILE try: - with metadata_file.open("w") as f: + with metadata_file.open("w", encoding="utf-8") as f: json.dump(metadata, f, indent=2) - return True - except Exception as e: - print(f"Error writing cloud metadata file: {e}") + 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 reflect which of the symbols, footprints, and templates + directories exist; 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, + "env_var": generate_env_var_name(name, "KICAD_LIB"), "capabilities": { - "symbols": has_symbols, - "footprints": has_footprints, - "templates": has_templates, + "symbols": (directory / "symbols").exists(), + "footprints": (directory / "footprints").exists(), + "templates": (directory / "templates").exists(), }, "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 get_default_cloud_metadata(directory: Path) -> CloudMetadata: + """Metadata for a new 3D model library in directory, named after the directory. - Args: - directory: Path to the cloud 3D model directory - - Returns: - Dictionary of metadata + model_count counts the model files anywhere below directory; env_var is + KICAD_3D_. """ - # Try to get a sensible name from the directory name = directory.name - - # 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") - + model_count = sum( + len(list(directory.glob(f"**/*{suffix}"))) for suffix in _MODEL_SUFFIXES + ) 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/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 0000000..2764612 --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,66 @@ +"""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_github_metadata, +) + + +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_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 From 927f3c88ee350d17e314a2d2f82581d1bd7b3ecf Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:37:52 +0200 Subject: [PATCH 024/105] fix(sync): detect new libraries in every pulled commit After a pull, sync compared HEAD~1 with HEAD, so a pull that brought several commits only reported the libraries added in the last one, and --auto-setup did not run when an earlier commit added a library. Compare with the HEAD from before the pull; check_for_library_changes takes that revision as an optional argument and keeps HEAD~1 as its default. --- kicad_lib_manager/commands/sync/command.py | 26 +++++++++++++--- tests/test_sync_command.py | 35 +++++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/kicad_lib_manager/commands/sync/command.py b/kicad_lib_manager/commands/sync/command.py index d22e039..ed9358f 100644 --- a/kicad_lib_manager/commands/sync/command.py +++ b/kicad_lib_manager/commands/sync/command.py @@ -97,6 +97,9 @@ def sync( continue try: + # Remember HEAD so the change check covers every pulled commit + revision_before_pull = _head_revision(lib_path) + # Run git pull result = subprocess.run( ["git", "pull"], @@ -130,7 +133,9 @@ def sync( # Check if there are new library files that would require setup if is_updated: - changes_require_setup = check_for_library_changes(lib_path) + changes_require_setup = check_for_library_changes( + lib_path, revision_before_pull or "HEAD~1" + ) if changes_require_setup: libraries_with_changes.append( (lib_name, lib_path, changes_require_setup) @@ -198,12 +203,25 @@ def sync( # TODO: Should be in services or utils -def check_for_library_changes(lib_path: Path) -> list[str]: +def _head_revision(lib_path: Path) -> str | None: + """Return the commit HEAD points at, or None when git cannot tell.""" + result = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", "HEAD"], + cwd=lib_path, + capture_output=True, + text=True, + check=False, + ) + return result.stdout.strip() or None if result.returncode == 0 else None + + +def check_for_library_changes(lib_path: Path, since: str = "HEAD~1") -> 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. + Uses git diff to analyze what files were added/changed since `since`. Args: lib_path: Path to the library directory + since: Revision to compare HEAD with, normally HEAD before the pull Returns: List of change types found ('symbols', 'footprints', 'templates') or empty list if none @@ -213,7 +231,7 @@ def check_for_library_changes(lib_path: Path) -> list[str]: 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"], + ["git", "diff", "--name-status", since, "HEAD"], cwd=lib_path, capture_output=True, text=True, diff --git a/tests/test_sync_command.py b/tests/test_sync_command.py index 3575c2b..b888125 100644 --- a/tests/test_sync_command.py +++ b/tests/test_sync_command.py @@ -9,6 +9,7 @@ from kicad_lib_manager.commands.setup import command as setup_command 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 from kicad_lib_manager.services.config_service import LibraryDict @@ -91,6 +92,20 @@ def test_sync_reports_an_up_to_date_library(library: Path) -> None: assert "No new libraries detected" in result.output +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") + + 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_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") @@ -106,7 +121,9 @@ def test_sync_auto_setup_runs_setup_with_its_defaults( upstream: Path, library: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Setup runs with its option defaults, so it looks up the library itself.""" - monkeypatch.setattr(setup_command, "find_environment_variables", lambda _name: None) + monkeypatch.setattr( + setup_command, "find_environment_variables", lambda _name: None + ) _commit_file(upstream, "symbols/New.kicad_sym", "add symbols") result = CliRunner().invoke(app, ["sync", "--auto-setup"]) @@ -140,3 +157,19 @@ def test_sync_without_libraries(monkeypatch: pytest.MonkeyPatch) -> None: assert result.exit_code == 0 assert "No GitHub libraries configured" in result.output + + +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", + ] From bb4386aa2adf5a29eaf7e0a86c6314b75d0a62b1 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:38:04 +0200 Subject: [PATCH 025/105] fix(add-hook): report a failure once and write the hook with LF endings The command-wide `except Exception` also caught the typer.Exit raised after an error had been printed (click's Exit is a RuntimeError), so a directory that is not a repository printed a second, empty "Error: 1" line. Let Exit pass through. The hook was written in text mode, which gives the shell script CRLF line endings on Windows; write the bytes atomically instead. --- .../commands/add_hook/command.py | 8 ++- tests/test_add_hook_command.py | 67 +++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 tests/test_add_hook_command.py diff --git a/kicad_lib_manager/commands/add_hook/command.py b/kicad_lib_manager/commands/add_hook/command.py index cae540d..6f26b1c 100644 --- a/kicad_lib_manager/commands/add_hook/command.py +++ b/kicad_lib_manager/commands/add_hook/command.py @@ -9,6 +9,7 @@ from kicad_lib_manager.utils.console import get_console +from ...utils.file_ops import write_atomically from ...utils.git_utils import ( backup_existing_hook, create_kilm_hook_content, @@ -100,9 +101,8 @@ def add_hook( new_content = create_kilm_hook_content() try: - # Write the hook content - with post_merge_hook.open("w") as f: - f.write(new_content) + # Binary write: text mode would give the shell script CRLF endings on Windows + write_atomically(post_merge_hook, new_content.encode("utf-8")) # Make the hook executable post_merge_hook.chmod(0o755) @@ -133,6 +133,8 @@ def add_hook( get_console().print(f"[red]Error creating hook: {e!s}[/red]") raise typer.Exit(1) from e + except typer.Exit: + raise except Exception as e: get_console().print(f"[red]Error: {e}[/red]") raise typer.Exit(1) from e 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 From 3a71b7894ffd3bce47692f59e5def4ae327d8c17 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:38:50 +0200 Subject: [PATCH 026/105] fix(update): honor --target-version and fail dry runs that cannot update UpdateService.perform_update dropped target_version, so an update always installed the latest release, and an up-to-date install refused to move to a requested older version. The installer now receives kilm==VERSION (pipx install --force, uv tool install, pip install --upgrade), the version is validated as a usage error, and asking for the installed version is a no-op without --force. A dry run on an installation method without automatic updates (conda, Homebrew, Nix, unknown) exited 0; it now exits 1 like the real update. --- kicad_lib_manager/commands/update/docs.mdx | 6 +- kicad_lib_manager/commands/update/perform.py | 45 ++++--- kicad_lib_manager/services/update_service.py | 121 ++++++++----------- tests/test_update_commands.py | 57 +++++++++ tests/test_update_service.py | 44 +++++++ 5 files changed, 185 insertions(+), 88 deletions(-) diff --git a/kicad_lib_manager/commands/update/docs.mdx b/kicad_lib_manager/commands/update/docs.mdx index 6bebb2e..46cc3f8 100644 --- a/kicad_lib_manager/commands/update/docs.mdx +++ b/kicad_lib_manager/commands/update/docs.mdx @@ -40,9 +40,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`. @@ -62,6 +62,8 @@ Every subcommand also accepts `--help`. 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 instruction and exits with status `1`. ## Examples diff --git a/kicad_lib_manager/commands/update/perform.py b/kicad_lib_manager/commands/update/perform.py index 70e25d9..9045cc9 100644 --- a/kicad_lib_manager/commands/update/perform.py +++ b/kicad_lib_manager/commands/update/perform.py @@ -4,6 +4,7 @@ from typing import Annotated import typer +from packaging.version import InvalidVersion, Version from rich.panel import Panel from kicad_lib_manager.utils.console import get_console @@ -54,6 +55,14 @@ def perform_update_command( get_console().print(Panel(deprecation_notice, expand=False, border_style="yellow")) version = importlib.metadata.version("kilm") + if target_version is not None: + try: + Version(target_version) + except InvalidVersion: + raise typer.BadParameter( + f"'{target_version}' is not a version number", + param_hint="--target-version", + ) from None update_service = UpdateService(version) if dry_run: @@ -62,8 +71,8 @@ def perform_update_command( ) get_console().print() - # Check if we can auto-update (skip during dry run) - if not dry_run and not update_service.can_auto_update(): + # A dry run fails the same way as the real update would. + if not update_service.can_auto_update(): get_console().print( "[yellow]⚠[/yellow] Automatic update not supported for your installation method." ) @@ -74,8 +83,17 @@ def perform_update_command( get_console().print(f"Update manually: [bold cyan]{update_cmd}[/bold cyan]") raise typer.Exit(1) - # Perform update check first (unless forced) - if not force and not dry_run: + if target_version is not None and not force and not dry_run: + if Version(target_version) == Version(version): + get_console().print( + f"[green]✓[/green] Already using version [bold cyan]{version}[/bold cyan]" + ) + raise typer.Exit(0) + get_console().print( + f"Updating from [blue]{version}[/blue] to [green]{target_version}[/green]" + ) + get_console().print() + elif not force and not dry_run: update_info = update_service.check_for_updates(use_cache=not no_cache) if not update_info["has_update"]: get_console().print( @@ -88,13 +106,10 @@ def perform_update_command( current = update_info["current_version"] latest = update_info["latest_version"] - target = target_version or latest - - if not dry_run: - get_console().print( - f"Updating from [blue]{current}[/blue] to [green]{target}[/green]" - ) - get_console().print() + get_console().print( + f"Updating from [blue]{current}[/blue] to [green]{latest}[/green]" + ) + get_console().print() # Perform the update success, message = update_service.perform_update( @@ -106,10 +121,10 @@ def perform_update_command( get_console().print( "\n[green]✓[/green] Dry run completed - automatic update is supported" ) - else: - get_console().print("\n[red]✗[/red] Automatic update not available") - get_console().print("Manual update will be required") - raise typer.Exit(0) + raise typer.Exit(0) + get_console().print("\n[red]✗[/red] Automatic update not available") + get_console().print("Manual update will be required") + raise typer.Exit(1) if success: get_console().print("\n[green]✓[/green] Update completed successfully!") diff --git a/kicad_lib_manager/services/update_service.py b/kicad_lib_manager/services/update_service.py index 03f48eb..b4738b8 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -226,44 +226,40 @@ def _save_cache(self, data: dict): json.dump(data, f) -def update_via_pipx() -> bool: - """Update KiLM via pipx. Most reliable method for CLI tools.""" +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. + """ + spec = f"kilm=={target_version}" if target_version else "kilm" + if method == "pipx": + if target_version: + return ["pipx", "install", "--force", spec] + return ["pipx", "upgrade", "kilm"] + if method == "uv": + if target_version: + return ["uv", "tool", "install", spec] + return ["uv", "tool", "upgrade", "kilm"] + if method in ("pip", "pip-venv"): + return ["pip", "install", "--upgrade", spec] + return None + + +def run_upgrade(command: list[str]) -> bool: + """Run an upgrade command from `upgrade_command` and report success.""" + # 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( - ["pipx", "upgrade", "kilm"], capture_output=True, text=True, timeout=300 + argv, capture_output=True, text=True, timeout=300, check=False ) - return result.returncode == 0 - except (subprocess.TimeoutExpired, FileNotFoundError): - return False - - -def update_via_pip() -> bool: - """Update KiLM via pip.""" - 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 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): + except (subprocess.TimeoutExpired, OSError): return False + return result.returncode == 0 class UpdateManager: @@ -287,53 +283,37 @@ def is_newer_version_available(self, latest_version: str) -> bool: except (InvalidVersion, AttributeError): return False - def get_update_instruction(self) -> str: + def get_update_instruction(self, target_version: str | None = None) -> str: """Get update instruction for the detected installation method.""" + command = upgrade_command(self.installation_method, target_version) + if command: + return " ".join(command) instructions: dict[InstallMethod, str] = { - "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)", "nix": "nix flake update, then rebuild the configuration or profile" " that installs kilm", "unknown": "Update KiLM with the tool that installed it", } - return instructions[self.installation_method] + return instructions.get(self.installation_method, "") 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"] + return upgrade_command(self.installation_method) is not None + + def perform_update(self, target_version: str | None = None) -> tuple[bool, str]: + """Install the latest release, or `target_version` when given. - def perform_update(self) -> tuple[bool, str]: - """Execute update using detected installation method. - Returns: (success: bool, message: str) + Returns: (success, message) """ - if not self.can_auto_update(): + command = upgrade_command(self.installation_method, target_version) + if command is None: instruction = self.get_update_instruction() return False, f"Manual update required: {instruction}" - - 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!" - instruction = self.get_update_instruction() - return False, f"Auto-update failed. Try manually: {instruction}" - except Exception as e: - return False, f"Update error: {e!s}" - else: - instruction = self.get_update_instruction() - return False, f"Unsupported installation method. Run: {instruction}" + if run_upgrade(command): + return True, "KiLM updated successfully!" + instruction = self.get_update_instruction(target_version) + return False, f"Auto-update failed. Try manually: {instruction}" class UpdateService: @@ -470,14 +450,13 @@ def perform_update( if dry_run: return self._show_dry_run_info(target_version) - # Check if update is needed (unless forced) - if not force: + # A target version is installed as asked, even when it is older. + if not force and target_version is None: 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() + return self.manager.perform_update(target_version) def _show_dry_run_info(self, target_version: str | None = None) -> tuple[bool, str]: """Show what would happen during an update.""" @@ -488,7 +467,7 @@ def _show_dry_run_info(self, target_version: str | None = None) -> tuple[bool, s 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() + update_cmd = self.manager.get_update_instruction(target_version) console.print( Panel( diff --git a/tests/test_update_commands.py b/tests/test_update_commands.py index 16d9652..9fb5290 100644 --- a/tests/test_update_commands.py +++ b/tests/test_update_commands.py @@ -359,6 +359,63 @@ def test_perform_target_version(self, runner, mock_update_service, mock_version) target_version="1.0.5", force=False, dry_run=False ) + def test_perform_dry_run_unsupported_method_exits_nonzero( + self, runner, mock_update_service, mock_version + ): + """A dry run fails like the real run when automatic update is impossible.""" + 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, ["--dry-run"]) + + assert result.exit_code == 1 + assert "Automatic update not supported" in result.stdout + mock_update_service.perform_update.assert_not_called() + + def test_perform_target_version_when_up_to_date( + self, runner, mock_update_service, mock_version + ): + """A target version installs even when no newer release exists.""" + 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", + } + mock_update_service.perform_update.return_value = (True, "done") + + result = runner.invoke(perform_app, ["--target-version", "0.9.0"]) + + assert result.exit_code == 0 + assert "Updating from 1.0.0 to 0.9.0" in result.stdout + mock_update_service.perform_update.assert_called_with( + target_version="0.9.0", force=False, dry_run=False + ) + + def test_perform_target_version_already_installed( + self, runner, mock_update_service, mock_version + ): + """Asking for the installed version changes nothing without --force.""" + mock_update_service.can_auto_update.return_value = True + + result = runner.invoke(perform_app, ["--target-version", "1.0.0"]) + + assert result.exit_code == 0 + assert "Already using version 1.0.0" in result.stdout + mock_update_service.perform_update.assert_not_called() + + def test_perform_rejects_invalid_target_version( + self, runner, mock_update_service, mock_version + ): + """A target that is not a version is a usage error, not an install.""" + mock_update_service.can_auto_update.return_value = True + + result = runner.invoke(perform_app, ["--target-version", ">=2"]) + + assert result.exit_code == 2 + mock_update_service.perform_update.assert_not_called() + 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 diff --git a/tests/test_update_service.py b/tests/test_update_service.py index b97f5d0..d063478 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -1,5 +1,6 @@ """Tests for the update service functionality including installation detection and update operations.""" +import subprocess import sys from pathlib import Path, PureWindowsPath from unittest.mock import Mock, patch @@ -424,6 +425,49 @@ def test_perform_update_force_when_no_update_needed(self, update_service): assert message == "Update successful" mock_perform.assert_called_once() + @pytest.mark.parametrize( + ("method", "target_version", "expected_argv"), + [ + ("pipx", None, ["pipx", "upgrade", "kilm"]), + ("pipx", "1.0.5", ["pipx", "install", "--force", "kilm==1.0.5"]), + ("uv", None, ["uv", "tool", "upgrade", "kilm"]), + ("uv", "1.0.5", ["uv", "tool", "install", "kilm==1.0.5"]), + ("pip", None, [sys.executable, "-m", "pip", "install", "--upgrade", "kilm"]), + ( + "pip-venv", + "1.0.5", + [sys.executable, "-m", "pip", "install", "--upgrade", "kilm==1.0.5"], + ), + ], + ) + def test_perform_update_installs_the_target_version( + self, monkeypatch, method, target_version, expected_argv + ): + """The installer receives the requested version, even a downgrade.""" + with patch( + "kicad_lib_manager.services.update_service.detect_installation_method", + return_value=method, + ): + service = UpdateService("1.0.0") + calls = [] + + def fake_run(argv, **_kwargs): + calls.append(argv) + return subprocess.CompletedProcess(argv, 0, "", "") + + monkeypatch.setattr( + "kicad_lib_manager.services.update_service.subprocess.run", fake_run + ) + # Already up to date: only a target version (or --force) installs. + with patch.object(service, "check_for_updates") as mock_check: + mock_check.return_value = {"has_update": False} + success, _ = service.perform_update( + target_version=target_version, force=target_version is None + ) + + assert success is True + assert calls == [expected_argv] + def test_dry_run_info_display(self, update_service): """Test dry run information display.""" with ( From 1e21ffc48b7f6382b83918d7677698ee208488d9 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:38:50 +0200 Subject: [PATCH 027/105] fix(update): update pinned uv tool installs to the latest release uv tool upgrade keeps an exact pin from an earlier install (for example uv tool install kilm==0.5.5 or kilm update perform --target-version) and exits 0 without changing anything, so kilm reported a successful update that did not happen. uv tool install kilm@latest replaces the pin; it is also a no-op on an unpinned install that is already current. --- kicad_lib_manager/commands/update/docs.mdx | 2 +- kicad_lib_manager/services/update_service.py | 7 ++++--- tests/test_update_service.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/kicad_lib_manager/commands/update/docs.mdx b/kicad_lib_manager/commands/update/docs.mdx index 46cc3f8..68c9753 100644 --- a/kicad_lib_manager/commands/update/docs.mdx +++ b/kicad_lib_manager/commands/update/docs.mdx @@ -54,7 +54,7 @@ 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 | diff --git a/kicad_lib_manager/services/update_service.py b/kicad_lib_manager/services/update_service.py index b4738b8..d0ddff8 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -241,9 +241,10 @@ def upgrade_command( return ["pipx", "install", "--force", spec] return ["pipx", "upgrade", "kilm"] if method == "uv": - if target_version: - return ["uv", "tool", "install", spec] - return ["uv", "tool", "upgrade", "kilm"] + # `uv tool upgrade` keeps an exact pin from an earlier install and + # then succeeds without changing anything; `uv tool install` replaces + # the pin. + return ["uv", "tool", "install", spec if target_version else "kilm@latest"] if method in ("pip", "pip-venv"): return ["pip", "install", "--upgrade", spec] return None diff --git a/tests/test_update_service.py b/tests/test_update_service.py index d063478..4690aed 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -265,7 +265,7 @@ def test_get_update_instructions(self): ("pipx", "pipx upgrade kilm"), ("pip", "pip install --upgrade kilm"), ("pip-venv", "pip install --upgrade kilm"), - ("uv", "uv tool upgrade kilm"), + ("uv", "uv tool install kilm@latest"), ("conda", "Conda package not yet available (planned for future)"), ("homebrew", "Homebrew package not yet available (planned for future)"), ( @@ -430,7 +430,7 @@ def test_perform_update_force_when_no_update_needed(self, update_service): [ ("pipx", None, ["pipx", "upgrade", "kilm"]), ("pipx", "1.0.5", ["pipx", "install", "--force", "kilm==1.0.5"]), - ("uv", None, ["uv", "tool", "upgrade", "kilm"]), + ("uv", None, ["uv", "tool", "install", "kilm@latest"]), ("uv", "1.0.5", ["uv", "tool", "install", "kilm==1.0.5"]), ("pip", None, [sys.executable, "-m", "pip", "install", "--upgrade", "kilm"]), ( From 6eb6465113f0925a6011490f2da11a551eca9e75 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:40:50 +0200 Subject: [PATCH 028/105] fix(update): answer version checks from the cache for a day PyPIVersionChecker ignored its cache except for the ETag, and UpdateService.check_for_updates(use_cache=...) called the same method in both branches. Every kilm --help, kilm update check, and kilm update info therefore queried PyPI, and --force, --force-check, and --no-cache changed nothing. A cached answer younger than a day is now returned without a request unless the cache is bypassed; stale entries still supply the ETag. The cache file location is injectable so the tests no longer write to the real ~/.cache/kilm. --- kicad_lib_manager/services/update_service.py | 124 ++++++++++--------- tests/test_update_service.py | 66 ++++++---- 2 files changed, 107 insertions(+), 83 deletions(-) diff --git a/kicad_lib_manager/services/update_service.py b/kicad_lib_manager/services/update_service.py index d0ddff8..b1b2823 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -160,70 +160,84 @@ def detect_installation_method() -> InstallMethod: class PyPIVersionChecker: - """Responsible PyPI API client with caching and proper headers.""" + """PyPI client for the latest release of a package, cached for a day. - def __init__(self, package_name: str, version: str = "unknown"): + 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. + """ + + CACHE_TTL_SECONDS = 24 * 60 * 60 + + def __init__( + self, + package_name: str, + version: str = "unknown", + *, + cache_file: Path | None = None, + ): 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.cache_file = cache_file or ( + 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. + 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 and never raise, so an + offline machine only loses the update notice. """ + cached_data = self._load_cache() + cached_version = cached_data.get("version") if cached_data else None + if use_cache and cached_data and self._is_fresh(cached_data): + return cached_version + + headers = {"User-Agent": self.user_agent} + if cached_data and cached_data.get("etag"): + headers["If-None-Match"] = cached_data["etag"] 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"] - response = requests.get(self.base_url, headers=headers, timeout=10) - 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"] - - # Cache response with ETag - self._save_cache( - { - "version": latest_version, - "etag": response.headers.get("ETag"), - "timestamp": time.time(), - } - ) - - return latest_version - - except (requests.RequestException, KeyError, json.JSONDecodeError): + latest_version = cached_version + etag = cached_data.get("etag") if cached_data else None + elif response.status_code == 200: + latest_version = response.json()["info"]["version"] + etag = response.headers.get("ETag") + else: + return None + except (requests.RequestException, KeyError, ValueError): # Fail silently - don't block CLI functionality - pass + return None - return None + if latest_version: + self._save_cache( + {"version": latest_version, "etag": etag, "timestamp": time.time()} + ) + return latest_version + + def _is_fresh(self, data: dict) -> bool: + return time.time() - data.get("timestamp", 0) < self.CACHE_TTL_SECONDS 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 + """Load cached version data of any age; None when absent or unreadable.""" + try: + with self.cache_file.open() as f: + data = json.load(f) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else 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) + """Save version data to cache; a cache that cannot be written is skipped.""" + try: + self.cache_file.parent.mkdir(parents=True, exist_ok=True) + with self.cache_file.open("w") as f: + json.dump(data, f) + except OSError: + pass def upgrade_command( @@ -271,9 +285,9 @@ def __init__(self, current_version: str): self.current_version = current_version self.installation_method: InstallMethod = 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 check_latest_version(self, *, use_cache: bool = True) -> str | None: + """Return the latest kilm release, from a fresh cache unless bypassed.""" + return self.version_checker.check_latest_version(use_cache=use_cache) def is_newer_version_available(self, latest_version: str) -> bool: """Compare versions to determine if update is available.""" @@ -335,11 +349,7 @@ def check_for_updates(self, use_cache: bool = True) -> dict[str, Any]: - 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() - ) + latest_version = self.manager.check_latest_version(use_cache=use_cache) has_update = False if latest_version: diff --git a/tests/test_update_service.py b/tests/test_update_service.py index 4690aed..15ccaa6 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -10,6 +10,7 @@ from kicad_lib_manager.services.update_service import ( InstallationDetector, + PyPIVersionChecker, UpdateManager, UpdateService, detect_installation_method, @@ -489,25 +490,25 @@ 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 version_checker(self, tmp_path): + """A checker whose cache lives in the test's temporary directory.""" + return PyPIVersionChecker( + "kilm", "1.0.0", cache_file=tmp_path / "version_check.json" + ) - 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) + @staticmethod + def pypi_response(version: str = "1.1.0") -> Mock: + """A successful PyPI JSON API response for `version`.""" + response = Mock() + response.status_code = 200 + response.json.return_value = {"info": {"version": version}} + response.headers.get.return_value = "etag123" + return response @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 + mock_get.return_value = self.pypi_response() version = version_checker.check_latest_version() @@ -523,19 +524,32 @@ def test_failed_version_check(self, mock_get, version_checker): 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 + def test_fresh_cache_answers_without_network(self, mock_get, version_checker): + """Within a day of the last check the cached version is the answer.""" + mock_get.return_value = self.pypi_response("1.1.0") + version_checker.check_latest_version() + mock_get.reset_mock() - version_checker._save_cache( - {"version": "1.0.5", "etag": "etag123", "timestamp": time.time()} - ) + assert version_checker.check_latest_version() == "1.1.0" + mock_get.assert_not_called() - mock_response = Mock() - mock_response.status_code = 304 - mock_get.return_value = mock_response + @patch("requests.get") + def test_bypassing_the_cache_queries_pypi(self, mock_get, version_checker): + """use_cache=False asks PyPI even when the cache is fresh.""" + mock_get.return_value = self.pypi_response("1.1.0") + version_checker.check_latest_version() + mock_get.return_value = self.pypi_response("1.2.0") - version = version_checker.check_latest_version() + assert version_checker.check_latest_version(use_cache=False) == "1.2.0" - assert version == "1.0.5" + @patch("requests.get") + def test_not_modified_response(self, mock_get, version_checker): + """A 304 answer keeps the cached version.""" + mock_get.return_value = self.pypi_response("1.0.5") + version_checker.check_latest_version() + not_modified = Mock() + not_modified.status_code = 304 + mock_get.return_value = not_modified + + assert version_checker.check_latest_version(use_cache=False) == "1.0.5" + assert mock_get.call_args.kwargs["headers"]["If-None-Match"] == "etag123" From 16dfe747ab55b071c601325baeb0ee4bd9fd2745 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:40:56 +0200 Subject: [PATCH 029/105] refactor: split env_vars lookups and kicad_common.json updates find_environment_variables tries one small lookup per source instead of one 20-branch function, catches only the errors each source can raise, and runs fish by its resolved path. The kicad_common.json updaters share load/save helpers; a null "vars" or pinned list (or a missing one) now reads as empty instead of raising TypeError, and a malformed file raises KiCadConfigError (a ValueError, like the invalid-JSON case). Duplicate names passed to update_pinned_libraries are pinned once. update_pinned_libraries takes its options by keyword, as every caller already does. update_kicad_env_vars keeps dry_run positional because setup and KiCadService pass it that way. --- kicad_lib_manager/utils/constants.py | 3 + kicad_lib_manager/utils/env_vars.py | 467 +++++++++++++-------------- tests/test_env_vars.py | 57 +++- 3 files changed, 274 insertions(+), 253 deletions(-) diff --git a/kicad_lib_manager/utils/constants.py b/kicad_lib_manager/utils/constants.py index c516482..8f76686 100644 --- a/kicad_lib_manager/utils/constants.py +++ b/kicad_lib_manager/utils/constants.py @@ -4,6 +4,9 @@ GITHUB_METADATA_FILE = "kilm.yaml" CLOUD_METADATA_FILE = ".kilm_metadata" +# KiCad's own configuration file in its configuration directory +KICAD_COMMON_FILE = "kicad_common.json" + # Configuration paths and directories CONFIG_DIR_NAME = "kicad-lib-manager" CONFIG_FILE_NAME = "config.yaml" diff --git a/kicad_lib_manager/utils/env_vars.py b/kicad_lib_manager/utils/env_vars.py index 4b7c66d..a665494 100644 --- a/kicad_lib_manager/utils/env_vars.py +++ b/kicad_lib_manager/utils/env_vars.py @@ -1,313 +1,284 @@ -"""Environment variable handling utilities""" +"""Library paths from the user's environment, and KiCad's kicad_common.json. + +find_environment_variables 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 import os import re import shutil import subprocess +from collections.abc import Callable, Mapping, Sequence from pathlib import Path - +from typing import cast + +from kicad_lib_manager.services.config_service import Config +from kicad_lib_manager.utils.backup import create_backup +from kicad_lib_manager.utils.constants import ( + CLOUD_METADATA_FILE, + DEFAULT_MAX_BACKUPS, + GITHUB_METADATA_FILE, + KICAD_COMMON_FILE, +) from kicad_lib_manager.utils.file_ops import write_atomically -# 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, - ) -except ImportError: - read_github_metadata = None - read_cloud_metadata = None - GITHUB_METADATA_FILE = "kilm.yaml" - CLOUD_METADATA_FILE = ".kilm_metadata" +JsonObject = dict[str, object] + + +class KiCadConfigError(ValueError): + """kicad_common.json is not valid JSON or not shaped as KiCad writes it.""" + + +# The library variables KiLM can derive from its configuration and from the +# metadata file of a library in the current directory. +_CONFIG_PATHS: dict[str, Callable[[Config], str | None]] = { + "KICAD_USER_LIB": lambda config: config.get_symbol_library_path(), + "KICAD_3D_LIB": lambda config: config.get_3d_library_path(), +} +_LIBRARY_MARKERS = { + "KICAD_USER_LIB": GITHUB_METADATA_FILE, + "KICAD_3D_LIB": CLOUD_METADATA_FILE, +} +_SHELL_FILES = (".bashrc", ".bash_profile", ".profile", ".zshrc") 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 + """Return the value of var_name from the first source that defines it. - Args: - var_name: The name of the environment variable to find + 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. Returns: - The value of the environment variable, or None if not found + The value, or None when no source defines it. """ - # 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 + lookups: tuple[Callable[[str], str | None], ...] = ( + _from_config, + _from_current_directory, + os.environ.get, + _from_fish, + _from_shell_files, + ) + for lookup in lookups: + value = lookup(var_name) + if value is not None: + return value + return None + + +def _from_config(var_name: str) -> str | None: + read_path = _CONFIG_PATHS.get(var_name) + if read_path is None: + return None + try: + path = read_path(Config()) + except (OSError, LookupError, TypeError, ValueError): + return None + return path or None + + +def _from_current_directory(var_name: str) -> str | None: + marker = _LIBRARY_MARKERS.get(var_name) + if marker is None: + return None try: current_dir = Path.cwd().resolve() + is_library = (current_dir / marker).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, # noqa: FBT001, FBT002 -- setup and KiCadService pass it positionally; make it keyword-only once they pass it by name + 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("\\", "/") - - 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) + updated.pop(key, None) + else: + updated[key] = value.replace("\\", "/") - write_atomically(kicad_common, json.dumps(config, indent=2).encode("utf-8")) - - return changes_needed + if updated == current: + return False + if not dry_run: + environment["vars"] = updated + _save_kicad_common(kicad_common, config, 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_symbol_libs": () if symbol_libs is None else symbol_libs, + "pinned_fp_libs": () 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": [], "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) - - write_atomically(kicad_common, json.dumps(config, indent=2).encode("utf-8")) - - 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: + _save_kicad_common(kicad_common, config, 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 _save_kicad_common( + kicad_common: Path, config: JsonObject, max_backups: int +) -> None: + create_backup(kicad_common, max_backups) + write_atomically(kicad_common, json.dumps(config, indent=2).encode("utf-8")) + + +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/tests/test_env_vars.py b/tests/test_env_vars.py index 14e988b..c60782b 100644 --- a/tests/test_env_vars.py +++ b/tests/test_env_vars.py @@ -6,13 +6,15 @@ import pytest +from kicad_lib_manager.utils import env_vars from kicad_lib_manager.utils.env_vars import ( + find_environment_variables, update_kicad_env_vars, update_pinned_libraries, ) -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" @@ -101,18 +103,18 @@ 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) -class _EncoderFailure(OSError): +class _DiskFullError(OSError): """Stands in for a disk error in the middle of writing JSON.""" def _failing_iterencode(*_args: object, **_kwargs: object) -> Iterator[str]: """Yield the start of a JSON document, then fail like a full disk.""" yield '{"environment": ' - raise _EncoderFailure + raise _DiskFullError @pytest.mark.parametrize( @@ -134,7 +136,52 @@ def test_failed_write_keeps_kicad_common( kicad_common.write_text(original, encoding="utf-8") monkeypatch.setattr(json.JSONEncoder, "iterencode", _failing_iterencode) - with pytest.raises(_EncoderFailure): + with pytest.raises(_DiskFullError): update(tmp_path) assert kicad_common.read_text(encoding="utf-8") == original + + +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(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """No source defines KICAD_USER_LIB; returns the empty home directory.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + 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_environment_variables("KICAD_USER_LIB") is None + + (isolated_sources / ".zshrc").write_text( + 'export KICAD_USER_LIB="/from/zshrc"\n', encoding="utf-8" + ) + assert find_environment_variables("KICAD_USER_LIB") == "/from/zshrc" + + monkeypatch.setenv("KICAD_USER_LIB", "/from/environment") + assert find_environment_variables("KICAD_USER_LIB") == "/from/environment" + + (tmp_path / "kilm.yaml").write_text("name: here\n", encoding="utf-8") + assert find_environment_variables("KICAD_USER_LIB") == str(tmp_path.resolve()) + + monkeypatch.setattr(_FakeConfig, "library_path", "/from/config") + assert find_environment_variables("KICAD_USER_LIB") == "/from/config" From 21e8356ea52aee2bd644ebf0c080e77a20ace1bc Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:41:13 +0200 Subject: [PATCH 030/105] fix(update): send the installed version in the PyPI User-Agent UpdateManager created its PyPIVersionChecker without the current version, so every request identified itself as KiLM/unknown. --- kicad_lib_manager/services/update_service.py | 2 +- tests/test_update_service.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/kicad_lib_manager/services/update_service.py b/kicad_lib_manager/services/update_service.py index b1b2823..3e7d189 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -281,7 +281,7 @@ class UpdateManager: """Manages update checking and execution for KiLM.""" def __init__(self, current_version: str): - self.version_checker = PyPIVersionChecker("kilm") + self.version_checker = PyPIVersionChecker("kilm", current_version) self.current_version = current_version self.installation_method: InstallMethod = detect_installation_method() diff --git a/tests/test_update_service.py b/tests/test_update_service.py index 15ccaa6..ce76043 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -236,6 +236,10 @@ def test_version_comparison(self, update_manager): 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_version_checks_identify_the_installed_version(self, update_manager): + """PyPI requests carry the running kilm version in the User-Agent.""" + assert update_manager.version_checker.user_agent.startswith("KiLM/1.0.0 ") + 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 From 4df0c11375d4f8fc19c355bc9c50497d84c9b5b3 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:41:36 +0200 Subject: [PATCH 031/105] fix(update): show why an automatic update failed run_upgrade captured the installer's output and discarded it, while kilm update perform told the user to check the error messages above, which were never printed. A failed update now reports the end of the installer's error output (or the timeout or launch error) followed by the manual command. --- kicad_lib_manager/commands/update/perform.py | 3 -- kicad_lib_manager/services/update_service.py | 38 +++++++++++++++----- tests/test_update_service.py | 22 ++++++++++++ 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/kicad_lib_manager/commands/update/perform.py b/kicad_lib_manager/commands/update/perform.py index 9045cc9..3d0d633 100644 --- a/kicad_lib_manager/commands/update/perform.py +++ b/kicad_lib_manager/commands/update/perform.py @@ -133,7 +133,4 @@ def perform_update_command( ) else: get_console().print(f"\n[red]✗[/red] Update failed: {message}") - get_console().print( - "Please try updating manually or check the error messages above" - ) raise typer.Exit(1) diff --git a/kicad_lib_manager/services/update_service.py b/kicad_lib_manager/services/update_service.py index 3e7d189..b750c9a 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -264,17 +264,38 @@ def upgrade_command( return None -def run_upgrade(command: list[str]) -> bool: - """Run an upgrade command from `upgrade_command` and report success.""" +UPGRADE_TIMEOUT_SECONDS = 300 +"""How long an installer may run before the update counts as failed.""" + +UPGRADE_ERROR_LINES = 10 +"""Trailing lines of installer output kept to explain a failed update.""" + + +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( - argv, capture_output=True, text=True, timeout=300, check=False + argv, + capture_output=True, + text=True, + timeout=UPGRADE_TIMEOUT_SECONDS, + check=False, ) - except (subprocess.TimeoutExpired, OSError): - return False - return result.returncode == 0 + 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}" class UpdateManager: @@ -325,10 +346,11 @@ def perform_update(self, target_version: str | None = None) -> tuple[bool, str]: if command is None: instruction = self.get_update_instruction() return False, f"Manual update required: {instruction}" - if run_upgrade(command): + error = run_upgrade(command) + if error is None: return True, "KiLM updated successfully!" instruction = self.get_update_instruction(target_version) - return False, f"Auto-update failed. Try manually: {instruction}" + return False, f"{error}\nTry manually: {instruction}" class UpdateService: diff --git a/tests/test_update_service.py b/tests/test_update_service.py index ce76043..8248bd9 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -473,6 +473,28 @@ def fake_run(argv, **_kwargs): assert success is True assert calls == [expected_argv] + def test_failed_update_reports_the_installer_error(self, monkeypatch): + """The installer's error output explains why an update failed.""" + with patch( + "kicad_lib_manager.services.update_service.detect_installation_method", + return_value="pipx", + ): + service = UpdateService("1.0.0") + + def fake_run(argv, **_kwargs): + error = "No matching distribution found for kilm==9.9.9\n" + return subprocess.CompletedProcess(argv, 1, "", error) + + monkeypatch.setattr( + "kicad_lib_manager.services.update_service.subprocess.run", fake_run + ) + + success, message = service.perform_update(target_version="9.9.9") + + assert success is False + assert "No matching distribution found for kilm==9.9.9" in message + assert "pipx install --force kilm==9.9.9" in message + def test_dry_run_info_display(self, update_service): """Test dry run information display.""" with ( From 931fefa42e11b19c783b22111be70f2ede6df0d5 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:43:30 +0200 Subject: [PATCH 032/105] refactor: type library table entries and drop unused writers list_configured_libraries returns LibraryTableEntry TypedDicts parsed by precompiled patterns. read_file_with_encoding takes the encodings as a sequence and raises the last decoding error when none fits. The unused add_symbol_lib and add_footprint_lib are removed; library tables are written by LibraryService. --- kicad_lib_manager/utils/file_ops.py | 355 ++++++++++------------------ 1 file changed, 120 insertions(+), 235 deletions(-) diff --git a/kicad_lib_manager/utils/file_ops.py b/kicad_lib_manager/utils/file_ops.py index 484f664..bb66967 100644 --- a/kicad_lib_manager/utils/file_ops.py +++ b/kicad_lib_manager/utils/file_ops.py @@ -1,42 +1,72 @@ -"""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 +from typing import Literal, TypedDict +_DEFAULT_ENCODINGS = ("utf-8", "utf-16", "iso-8859-1") -def read_file_with_encoding(file_path: Path, encodings: list[str] | None = None) -> str: - """Read a file trying multiple encodings until successful +# One (lib ...) entry per line, as KiCad writes library tables. +_LIB_ENTRY_RE = re.compile(r"^\s*\(lib\s+(.*?)\)\s*$", re.MULTILINE) +_LIB_NAME_RE = re.compile(r'\(name\s+"([^"]+)"\)') +# Optional properties of an entry; "url" is accepted for "uri" too. +_LIB_PROPERTY_RES: dict[Literal["uri", "type", "description"], re.Pattern[str]] = { + "uri": re.compile(r'\((?:uri|url)\s+"([^"]+)"\)'), + "type": re.compile(r'\(type\s+"([^"]+)"\)'), + "description": re.compile(r'\(descr\s+"([^"]+)"\)'), +} - 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 +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: @@ -45,23 +75,18 @@ def write_file_with_encoding( 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. - - Args: - file_path: Path to the file to write - content: Content to write - encoding: Encoding to use (defaults to utf-8) """ 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") @@ -81,237 +106,97 @@ 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, # noqa: FBT001, FBT002 -- LibraryService passes it positionally; make it keyword-only once it passes it by name +) -> 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 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 - 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 - 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. 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) - - # 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) - - 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) - - name_match = re.search(r'\(name\s+"([^"]+)"\)', entry) - if not name_match: - continue - lib_info = {"name": name_match.group(1)} - - 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) + return ( + _read_lib_table(kicad_config / "sym-lib-table"), + _read_lib_table(kicad_config / "fp-lib-table"), + ) - footprint_libs.append(lib_info) - return symbol_libs, footprint_libs +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 + + +def _parse_lib_entry(entry: str) -> LibraryTableEntry | None: + name = _LIB_NAME_RE.search(entry) + if name is None: + return None + parsed: LibraryTableEntry = {"name": str(name.group(1))} + for key, pattern in _LIB_PROPERTY_RES.items(): + value = pattern.search(entry) + if value is not None: + parsed[key] = str(value.group(1)) + return parsed From a1d99a42dc120a5ff94f64ae80e26f8717588a91 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:44:35 +0200 Subject: [PATCH 033/105] build: depend on typing-extensions directly The CLI uses typing_extensions.override, which Python 3.10 and 3.11 lack in the typing module. It was only installed through typer before. --- pyproject.toml | 1 + uv.lock | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 16f438b..a329503 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "jinja2>=3.1.6", "questionary>=1.10.0", "requests>=2.32.4", + "typing-extensions>=4.4.0", ] [dependency-groups] 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] From 579cdcb6a50dd64378a996b56fa214dc56aea720 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:44:35 +0200 Subject: [PATCH 034/105] refactor(main): pass the strict ruff and ty gates Use absolute imports and the public Config instead of ConfigService._config, mark BannerGroup.format_help with @override, and log a failed update check at debug level instead of discarding it. The no-argument banner now follows ctx.invoked_subcommand rather than sys.argv, so it also shows when the app is invoked programmatically. Help, version, and banner output are unchanged. Add docstrings to the command packages and the package entry points, and move the KICAD_USER_LIB fallback that several commands share into commands/shared.py, starting with list. --- kicad_lib_manager/__init__.py | 2 +- kicad_lib_manager/__main__.py | 4 +- kicad_lib_manager/commands/add_3d/__init__.py | 4 +- .../commands/add_hook/__init__.py | 4 +- kicad_lib_manager/commands/init/__init__.py | 4 +- .../commands/list_libraries/__init__.py | 4 +- .../commands/list_libraries/command.py | 118 +++++-------- kicad_lib_manager/commands/pin/__init__.py | 4 +- kicad_lib_manager/commands/setup/__init__.py | 4 +- kicad_lib_manager/commands/shared.py | 32 ++++ kicad_lib_manager/commands/status/__init__.py | 4 +- kicad_lib_manager/commands/sync/__init__.py | 4 +- kicad_lib_manager/commands/unpin/__init__.py | 4 +- kicad_lib_manager/main.py | 166 +++++++++--------- 14 files changed, 187 insertions(+), 171 deletions(-) create mode 100644 kicad_lib_manager/commands/shared.py diff --git a/kicad_lib_manager/__init__.py b/kicad_lib_manager/__init__.py index edd21cd..a2d4af4 100644 --- a/kicad_lib_manager/__init__.py +++ b/kicad_lib_manager/__init__.py @@ -1 +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 f532067..ae3c35b 100644 --- a/kicad_lib_manager/__main__.py +++ b/kicad_lib_manager/__main__.py @@ -1,6 +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/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_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/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/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 22d6154..49bd3d4 100644 --- a/kicad_lib_manager/commands/list_libraries/command.py +++ b/kicad_lib_manager/commands/list_libraries/command.py @@ -1,4 +1,4 @@ -"""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 @@ -6,18 +6,40 @@ import typer from rich.table import Table +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 ...services.library_service import LibraryService -from ...utils.env_vars import expand_user_path, find_environment_variables + +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)", + help=( + "KiCad library directory (uses KICAD_USER_LIB env var if not specified)" + ), envvar="KICAD_USER_LIB", ), ] = None, @@ -27,75 +49,25 @@ def list_cmd( 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: - get_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)) - - get_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") - - get_console().print(symbol_table) - else: - get_console().print("[yellow]No symbol libraries found[/yellow]") - - get_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") - - get_console().print(footprint_table) - else: - get_console().print("[yellow]No footprint libraries found[/yellow]") - - # Summary information - total_libs = len(symbols) + len(footprints) - if total_libs > 0: - get_console().print( - f"\n[green]Found {total_libs} libraries total[/green] " - f"([cyan]{len(symbols)} symbol[/cyan], " - f"[magenta]{len(footprints)} footprint[/magenta])" - ) - else: - get_console().print( - "[yellow]No libraries found in the specified directory[/yellow]" - ) - + symbols, footprints = LibraryService().list_libraries(Path(library_dir)) except Exception as e: - get_console().print(f"[red]Error listing libraries: {e}[/red]") + 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/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/shared.py b/kicad_lib_manager/commands/shared.py new file mode 100644 index 0000000..61dce72 --- /dev/null +++ b/kicad_lib_manager/commands/shared.py @@ -0,0 +1,32 @@ +"""Option handling shared by several commands.""" + +import typer + +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.env_vars import ( + expand_user_path, + find_environment_variables, +) + + +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 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_environment_variables`` searches. Prints an error and exits with + status 1 when neither names a directory. + """ + directory = given(option) or given(find_environment_variables("KICAD_USER_LIB")) + 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/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/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/main.py b/kicad_lib_manager/main.py index cf36d85..0553b34 100644 --- a/kicad_lib_manager/main.py +++ b/kicad_lib_manager/main.py @@ -1,84 +1,88 @@ -#!/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.text import Text from typer.core import TyperGroup - +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 -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 - TAGLINE = "Professional KiCad library management" +logger = logging.getLogger(__name__) -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 - # Skip if update checking is disabled - if not config.get("update_check", True): - return +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() + - version = importlib.metadata.version("kilm") - update_service = UpdateService(version) +def _check_for_updates_if_needed() -> None: + """Show a notice when a newer KiLM is available, unless disabled. - # Always show notification if update is available, regardless of frequency - # Only respect frequency for the actual PyPI API calls + PyPI is queried only as often as the configured frequency allows; between + queries the cached answer still produces the notice. 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 + 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 - console = get_console() - 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, @@ -88,20 +92,22 @@ def format_help(self, ctx, formatter): ) -def version_callback(value: bool) -> None: - """Print version information and exit.""" - if value: - 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() +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( @@ -113,32 +119,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:]: - console = get_console() - 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 @@ -154,7 +146,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") From f1842cf43a8c2dd2d9b7cf2a3ce6214b06405de0 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:44:58 +0200 Subject: [PATCH 035/105] fix(template): skip excluded directories when suggesting variables Without a .gitignore, template make scanned .git, virtual environments, and node_modules for variable values and offered every package author it found. The scan now skips the directories that make never copies. --- kicad_lib_manager/utils/template.py | 40 +++++++++++++++++------------ tests/test_template.py | 9 +++++++ 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/kicad_lib_manager/utils/template.py b/kicad_lib_manager/utils/template.py index ed733ca..9a12541 100644 --- a/kicad_lib_manager/utils/template.py +++ b/kicad_lib_manager/utils/template.py @@ -42,6 +42,26 @@ r'author\s*[=:]\s*["\']([^"\']+)["\']', ] +# 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, + } +) + # KiCad file extensions KICAD_PROJECT_EXT = ".kicad_pro" KICAD_SCHEMATIC_EXT = ".kicad_sch" @@ -241,7 +261,8 @@ def find_potential_variables( gitignore_spec = get_gitignore_spec(directory) # Walk directory - for root, _, files in os.walk(directory): + for root, dirs, files in os.walk(directory): + dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRECTORIES] rel_root = os.path.relpath(root, directory) if rel_root == ".": rel_root = "" @@ -455,22 +476,7 @@ def create_template_structure( "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 - ] + common_excludes = EXCLUDED_DIRECTORIES # Debug gitignore if available if gitignore_spec: diff --git a/tests/test_template.py b/tests/test_template.py index b397d77..82e9dde 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -19,6 +19,7 @@ create_template_metadata, create_template_structure, find_all_templates, + find_potential_variables, render_filename, render_filename_custom, render_template_file, @@ -372,5 +373,13 @@ def test_rendered_files_keep_markup_characters(tmp_path: Path) -> None: assert target.read_text() == "R&D <1>" +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"') + (tmp_path / "notes.txt").write_text('author = "Jane"') + + assert find_potential_variables(tmp_path) == {"jane": ["Jane"]} + if __name__ == "__main__": pytest.main() From d46de84b71558967e21902991333c1fbace43324 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:44:58 +0200 Subject: [PATCH 036/105] fix(template): make the make --dry-run preview match the copy The preview applied only .gitignore and --exclude, so it listed .git, virtual environments, the template's own output directory, and .kicad_prl files that make never copies. --- .../commands/template/command.py | 13 ++++++++-- tests/test_template_commands.py | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/kicad_lib_manager/commands/template/command.py b/kicad_lib_manager/commands/template/command.py index b398b26..9cd5a4a 100644 --- a/kicad_lib_manager/commands/template/command.py +++ b/kicad_lib_manager/commands/template/command.py @@ -30,6 +30,8 @@ POST_CREATE_HOOK, TEMPLATE_CONTENT_DIR, TEMPLATE_METADATA, + EXCLUDED_DIRECTORIES, + KICAD_PRL_EXT, TEMPLATES_DIR, TemplateVariable, create_project_from_template, @@ -798,8 +800,12 @@ def make( if not git_path.endswith("/"): git_path += "/" - if (gitignore_spec and gitignore_spec.match_file(git_path)) or ( - additional_spec and additional_spec.match_file(git_path) + if ( + d in EXCLUDED_DIRECTORIES + or output_directory.as_posix() + in str(source_directory / rel_path) + or (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}/") @@ -820,6 +826,9 @@ def make( if additional_spec and additional_spec.match_file(git_path): excluded_files.append(rel_path) continue + if file.lower().endswith(KICAD_PRL_EXT): + excluded_files.append(rel_path) + continue included_files.append(rel_path) diff --git a/tests/test_template_commands.py b/tests/test_template_commands.py index be62d24..1af3954 100644 --- a/tests/test_template_commands.py +++ b/tests/test_template_commands.py @@ -171,3 +171,28 @@ def test_make_keeps_description_when_detected_variable_is_described( 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"] From b4beb85638504612f136b86d817d4220941f919e Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:45:23 +0200 Subject: [PATCH 037/105] fix: report a missing git and keep every hook backup get_git_hooks_directory resolves git on PATH and raises RuntimeError when it is missing, which add-hook already reports, instead of leaking FileNotFoundError. backup_existing_hook names backups like other KiLM backups, so two within one second no longer overwrite each other, and copies the hook with its permission bits instead of re-encoding it. The hook functions and their tests now meet the strict lint and type rules. --- kicad_lib_manager/utils/git_utils.py | 157 +++++++++++---------------- tests/test_git_utils.py | 33 ++++-- 2 files changed, 87 insertions(+), 103 deletions(-) diff --git a/kicad_lib_manager/utils/git_utils.py b/kicad_lib_manager/utils/git_utils.py index 038f19c..fb40bb6 100644 --- a/kicad_lib_manager/utils/git_utils.py +++ b/kicad_lib_manager/utils/git_utils.py @@ -1,131 +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) + + 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 - # Ask Git for the effective hooks directory. Handles core.hooksPath, worktrees, and common 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. - - Args: - hook_path: Path to the existing hook file + """Copy hook_path to a timestamped sibling and return the copy's path. - 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. - - Args: - existing_content: Content of existing hook - kilm_content: KiLM hook content to add + """Return existing_content with kilm_content as its KiLM-managed section. - 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 @@ -136,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/tests/test_git_utils.py b/tests/test_git_utils.py index d7c49dd..e62e845 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -6,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, @@ -17,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() @@ -28,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'" @@ -46,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() @@ -58,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' @@ -79,7 +80,9 @@ def test_merge_hook_content_replace_existing(self): assert "kilm sync" in result @patch("subprocess.run") - def test_get_git_hooks_directory_standard(self, mock_run, tmp_path): + def test_get_git_hooks_directory_standard( + self, mock_run: Mock, tmp_path: Path + ) -> None: """Test standard hooks directory detection.""" # Mock the first call to succeed with hooks path mock_run.return_value = Mock(returncode=0, stdout=".git/hooks") @@ -94,7 +97,9 @@ def test_get_git_hooks_directory_standard(self, mock_run, tmp_path): mock_run.assert_called_once() @patch("subprocess.run") - def test_get_git_hooks_directory_custom_path(self, mock_run, tmp_path): + def test_get_git_hooks_directory_custom_path( + self, mock_run: Mock, tmp_path: Path + ) -> None: """Test custom hooks directory detection.""" custom_hooks = tmp_path / "custom" / "hooks" custom_hooks.mkdir(parents=True, exist_ok=True) @@ -111,7 +116,9 @@ def test_get_git_hooks_directory_custom_path(self, mock_run, tmp_path): mock_run.assert_called_once() @patch("subprocess.run") - def test_get_git_hooks_directory_relative_path(self, mock_run, tmp_path): + def test_get_git_hooks_directory_relative_path( + self, mock_run: Mock, tmp_path: Path + ) -> None: """Test relative custom hooks path handling.""" mock_run.return_value = Mock(returncode=0, stdout="custom/hooks") @@ -126,7 +133,9 @@ def test_get_git_hooks_directory_relative_path(self, mock_run, tmp_path): mock_run.assert_called_once() @patch("subprocess.run") - def test_get_git_hooks_directory_worktree(self, mock_run, tmp_path): + def test_get_git_hooks_directory_worktree( + self, mock_run: Mock, tmp_path: Path + ) -> None: """Test Git worktree hooks directory detection.""" # Mock the Git command to return the worktree hooks path main_git_dir = tmp_path / "main-repo" / ".git" @@ -143,6 +152,14 @@ def test_get_git_hooks_directory_worktree(self, mock_run, tmp_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 ) -> None: From a4e663c67c92e8ef807e392f7c246ed17fa3d755 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:45:23 +0200 Subject: [PATCH 038/105] refactor: tidy banner, constants, and utils package for strict lint The banner keeps its art and colors in module constants. DEFAULT_LIBRARIES keeps an untyped list with a reasoned ignore: its element type lives in config_service, which imports constants. --- kicad_lib_manager/utils/__init__.py | 4 +- kicad_lib_manager/utils/banner.py | 86 +++++++++++----------------- kicad_lib_manager/utils/constants.py | 2 +- 3 files changed, 35 insertions(+), 57 deletions(-) diff --git a/kicad_lib_manager/utils/__init__.py b/kicad_lib_manager/utils/__init__.py index f30ce6e..528d2d8 100644 --- a/kicad_lib_manager/utils/__init__.py +++ b/kicad_lib_manager/utils/__init__.py @@ -1,5 +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/banner.py b/kicad_lib_manager/utils/banner.py index b2d9373..ca3e912 100644 --- a/kicad_lib_manager/utils/banner.py +++ b/kicad_lib_manager/utils/banner.py @@ -1,67 +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/constants.py b/kicad_lib_manager/utils/constants.py index 8f76686..38129e1 100644 --- a/kicad_lib_manager/utils/constants.py +++ b/kicad_lib_manager/utils/constants.py @@ -23,4 +23,4 @@ # Default configuration values DEFAULT_MAX_BACKUPS = 5 -DEFAULT_LIBRARIES: list = [] +DEFAULT_LIBRARIES: list = [] # ty: ignore[missing-type-argument] -- element type lives in config_service, which imports this module; inline [] there and drop this constant From 498c29bd30c87f3b8c250de7de3b8b6a389d29a2 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:46:18 +0200 Subject: [PATCH 039/105] refactor(pin, unpin): pass the strict ruff and ty gates Split both commands into small steps (find the configuration, choose the libraries, apply, report) and make their options keyword-only. Unpin now reads kicad_common.json through typed helpers in commands/kicad_common.py instead of indexing untyped JSON, and treats a non-object session like a missing one. Pin narrows its best-effort library validation to OSError, the only error listing raises. Output is unchanged. --- kicad_lib_manager/commands/kicad_common.py | 62 ++++ kicad_lib_manager/commands/pin/command.py | 247 +++++++------ kicad_lib_manager/commands/unpin/command.py | 375 +++++++++++--------- 3 files changed, 382 insertions(+), 302 deletions(-) create mode 100644 kicad_lib_manager/commands/kicad_common.py diff --git a/kicad_lib_manager/commands/kicad_common.py b/kicad_lib_manager/commands/kicad_common.py new file mode 100644 index 0000000..0ce3e35 --- /dev/null +++ b/kicad_lib_manager/commands/kicad_common.py @@ -0,0 +1,62 @@ +"""Typed access to KiCad's ``kicad_common.json``. + +KiCad keeps environment variables under ``environment.vars`` and the pinned +libraries under ``session.pinned_symbol_libs`` and ``session.pinned_fp_libs``. +The helpers here read the JSON into plain objects, narrow the parts the +commands use, and write the file back atomically, so a failed write never +leaves KiCad with a truncated configuration. +""" + +import json +from pathlib import Path + +from kicad_lib_manager.utils.file_ops import write_atomically + +KICAD_COMMON_FILE = "kicad_common.json" +PINNED_SYMBOLS_KEY = "pinned_symbol_libs" +PINNED_FOOTPRINTS_KEY = "pinned_fp_libs" + +JsonObject = dict[str, object] + + +def as_json_object(value: object) -> JsonObject | None: + """Return ``value`` as a JSON object, or None when it is not one.""" + 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 ``value`` when it is a JSON array, 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 save_json_object(path: Path, data: JsonObject) -> None: + """Replace ``path`` atomically with ``data`` as indented JSON.""" + write_atomically(path, json.dumps(data, indent=2).encode("utf-8")) diff --git a/kicad_lib_manager/commands/pin/command.py b/kicad_lib_manager/commands/pin/command.py index 0c9ff38..b1d2138 100644 --- a/kicad_lib_manager/commands/pin/command.py +++ b/kicad_lib_manager/commands/pin/command.py @@ -1,4 +1,4 @@ -"""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 @@ -7,24 +7,114 @@ from rich.panel import Panel from rich.table import Table +from kicad_lib_manager.commands.shared import resolve_kicad_lib_dir +from kicad_lib_manager.services.kicad_service import KiCadService +from kicad_lib_manager.services.library_service import LibraryService from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.env_vars import update_pinned_libraries -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, -) + +def _find_kicad_config(*, verbose: bool) -> Path: + """Return KiCad's configuration directory; exit when it cannot be found.""" + console = get_console() + try: + kicad_config = KiCadService().find_kicad_config_dir() + 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 _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( +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)", + help=( + "KiCad library directory (uses KICAD_USER_LIB env var if not specified)" + ), ), ] = None, symbols: Annotated[ @@ -78,134 +168,35 @@ def pin( 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: - get_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: get_console().print( - f"[blue]Using KiCad library directory:[/blue] {kicad_lib_dir}" + f"[blue]Using KiCad library directory:[/blue] {library_dir}" ) + kicad_config = _find_kicad_config(verbose=verbose) - # Initialize services - library_service = LibraryService() - kicad_service = KiCadService() - - # Find KiCad configuration - try: - kicad_config = kicad_service.find_kicad_config_dir() - if verbose: - get_console().print( - f"[blue]Found KiCad configuration at:[/blue] {kicad_config}" - ) - except Exception as e: - get_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: - get_console().print( - f"[green]Found {len(symbols)} symbol libraries and {len(footprints)} footprint libraries[/green]" - ) - except Exception as e: - get_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) - ) - - # Check symbols - for symbol in symbols: - if symbol not in available_symbols: - get_console().print( - f"[yellow]Warning: Symbol library '{symbol}' not found[/yellow]" - ) - - # Check footprints - for footprint in footprints: - if footprint not in available_footprints: - get_console().print( - f"[yellow]Warning: Footprint library '{footprint}' not found[/yellow]" - ) - except Exception as e: - get_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: - get_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]" - get_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: - get_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) - get_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) - get_console().print(table) except Exception as e: 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/unpin/command.py b/kicad_lib_manager/commands/unpin/command.py index 853de3f..d5577f0 100644 --- a/kicad_lib_manager/commands/unpin/command.py +++ b/kicad_lib_manager/commands/unpin/command.py @@ -1,198 +1,225 @@ -"""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.panel import Panel +from kicad_lib_manager.commands.kicad_common import ( + KICAD_COMMON_FILE, + PINNED_FOOTPRINTS_KEY, + PINNED_SYMBOLS_KEY, + as_list, + load_json_object, + pinned_names, + save_json_object, + session_of, +) +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 ...services.library_service import LibraryService -from ...utils.backup import create_backup -from ...utils.file_ops import write_atomically - - -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): - get_console().print( - "[red]Error: '--all' cannot be used with '--symbols' or '--footprints'[/red]" - ) - raise typer.Exit(1) +NO_SESSION = ( + "[yellow]No session information found in KiCad configuration, " + "nothing to unpin[/yellow]" +) - # Find KiCad configuration + +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: - get_console().print( - f"Found KiCad configuration at: [blue]{kicad_config}[/blue]" - ) except Exception as e: - get_console().print(f"[red]Error finding KiCad configuration: {e}[/red]") + 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 + create_backup(kicad_common, max_backups) + save_json_object(kicad_common, config) + 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") + ] = 5, + verbose: Annotated[ + bool, + typer.Option("--verbose", "-v", help="Show verbose output for debugging"), + ] = False, +) -> None: + """Unpin libraries in KiCad.""" + 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(): - get_console().print( - "[yellow]KiCad common configuration file not found, nothing to unpin[/yellow]" + console.print( + "[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: - get_console().print( - f"Found [cyan]{len(symbols)}[/cyan] pinned symbol libraries" - ) - if footprints: - get_console().print( - f"Found [cyan]{len(footprints)}[/cyan] pinned footprint libraries" - ) - else: - get_console().print( - "[yellow]No session information found in KiCad configuration, nothing to unpin[/yellow]" - ) - return - - if not symbols and not footprints: - get_console().print( - "[yellow]No pinned libraries found, nothing to unpin[/yellow]" - ) - return - except Exception as e: - get_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: - get_console().print("[red]Error: No libraries specified to unpin[/red]") - get_console().print( - "[yellow]Use --symbols, --footprints, or --all to specify libraries to unpin[/yellow]" + 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]" ) 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: - get_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) - - write_atomically(kicad_common, json.dumps(config, indent=2).encode()) - - if changes_needed: - if dry_run: - get_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]" - get_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: - get_console().print( - "[yellow]No changes needed, libraries already unpinned in KiCad[/yellow]" - ) - - if verbose: - if symbols: - get_console().print( - "\n[bold cyan]Unpinned Symbol Libraries:[/bold cyan]" - ) - for symbol in sorted(symbols): - get_console().print(f" • [cyan]{symbol}[/cyan]") - - if footprints: - get_console().print( - "\n[bold cyan]Unpinned Footprint Libraries:[/bold cyan]" - ) - for footprint in sorted(footprints): - get_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: - get_console().print(f"[red]Error unpinning libraries: {e}[/red]") + 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) From 03aee5e33e6141c91055f9ff9edb66f67c4bc86a Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:46:51 +0200 Subject: [PATCH 040/105] fix(init): report the library folders it creates The library service creates the symbols, footprints, and templates folders before init checked them, so init always called every folder existing and never printed "Created new folders". Note the directory contents before the service runs instead. --- kicad_lib_manager/commands/init/command.py | 6 ++-- tests/test_init_command.py | 34 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 tests/test_init_command.py diff --git a/kicad_lib_manager/commands/init/command.py b/kicad_lib_manager/commands/init/command.py index ce193cf..604ba50 100644 --- a/kicad_lib_manager/commands/init/command.py +++ b/kicad_lib_manager/commands/init/command.py @@ -85,6 +85,9 @@ def init( ) ) + # The service creates the library folders, so note what exists beforehand. + present_before = {entry.name for entry in current_dir.iterdir()} + # Use library service to initialize library_service = LibraryService() try: @@ -107,8 +110,7 @@ def init( for folder_type, exists in capabilities.items(): if exists: - folder_path = current_dir / folder_type - if folder_path.exists(): + if folder_type in present_before: existing_folders.append(folder_type) else: created_folders.append(folder_type) diff --git a/tests/test_init_command.py b/tests/test_init_command.py new file mode 100644 index 0000000..7dd128a --- /dev/null +++ b/tests/test_init_command.py @@ -0,0 +1,34 @@ +"""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, with a private home.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + 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() From 3dce93e128186c6dd16c9c24f33ce97061d2a549 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:47:09 +0200 Subject: [PATCH 041/105] refactor: bring the import command up to the strict lint rules ImportFailure is renamed ImportZipError. build_target takes the user's choice of libraries as a LibrarySelection and dry_run by keyword, and the Typer command takes its parameters by keyword like the other commands. The lzma error lookup runs at import time instead of importing inside a function, imports are absolute, and the kicad-cli call states why its arguments are safe. CLI help and behavior are unchanged. --- .../commands/import_zip/__init__.py | 4 +- .../commands/import_zip/archive.py | 42 +++++++------- .../commands/import_zip/command.py | 46 ++++++++++------ .../commands/import_zip/errors.py | 2 +- .../commands/import_zip/formats.py | 7 ++- .../commands/import_zip/importer.py | 24 ++++---- .../commands/import_zip/kicad_cli.py | 11 ++-- .../commands/import_zip/symbols.py | 15 ++--- .../commands/import_zip/target.py | 55 +++++++++++++------ tests/import_zip/conftest.py | 2 +- tests/import_zip/test_archive.py | 7 +-- tests/import_zip/test_cli.py | 2 +- tests/import_zip/test_formats.py | 9 ++- tests/import_zip/test_models.py | 3 +- tests/import_zip/test_symbols.py | 7 +-- tests/import_zip/test_writes.py | 3 +- 16 files changed, 140 insertions(+), 99 deletions(-) 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 ea22835..6e8d7ae 100644 --- a/kicad_lib_manager/commands/import_zip/command.py +++ b/kicad_lib_manager/commands/import_zip/command.py @@ -11,16 +11,20 @@ import typer +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 -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 - -def import_zip( +def import_zip( # noqa: PLR0913 -- each CLI option is a Typer parameter + *, zip_files: Annotated[ list[Path], typer.Argument( @@ -40,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[ @@ -48,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[ @@ -64,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. @@ -74,7 +84,10 @@ 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: get_console().print("[yellow]Dry run - no changes will be made[/yellow]") @@ -88,14 +101,14 @@ def import_zip( else: totals.extend(result) - _print_summary(totals, dry_run) + _print_summary(totals, dry_run=dry_run) if failed: 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(): get_console().print(f"[red]kicad-cli not found at: {path}[/red]") raise typer.Exit(1) @@ -118,14 +131,14 @@ def _import_one(zip_path: Path, target: ImportTarget) -> ZipResult | None: get_console().print(f"\n[cyan]Importing {zip_path.name}[/cyan]") try: return import_zip_file(zip_path, target) - except (ImportFailure, OSError) as exc: + 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: +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)}") @@ -138,7 +151,8 @@ def _print_summary(totals: ZipResult, dry_run: bool) -> None: elif totals.symbols or totals.footprints or totals.models: 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]" + "[dim]Note: If this library is not yet configured in KiCad, " + "run 'kilm setup' to register it.[/dim]" ) else: 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 e20f82e..982d65e 100644 --- a/kicad_lib_manager/commands/import_zip/importer.py +++ b/kicad_lib_manager/commands/import_zip/importer.py @@ -6,15 +6,14 @@ from dataclasses import dataclass, field from pathlib import Path +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 ...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 +from kicad_lib_manager.utils.file_ops import write_atomically @dataclass(frozen=True) @@ -26,6 +25,7 @@ class ModelRef: @property def path(self) -> str: + """The reference footprints use for this model.""" return f"{self.prefix}/{self.name}" @@ -71,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) @@ -112,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. @@ -158,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: diff --git a/kicad_lib_manager/commands/import_zip/kicad_cli.py b/kicad_lib_manager/commands/import_zip/kicad_cli.py index 8c2b394..c53ce8f 100644 --- a/kicad_lib_manager/commands/import_zip/kicad_cli.py +++ b/kicad_lib_manager/commands/import_zip/kicad_cli.py @@ -9,10 +9,9 @@ from dataclasses import dataclass from pathlib import Path +from kicad_lib_manager.commands.import_zip.formats import format_version from kicad_lib_manager.utils.console import get_console -from .formats import format_version - _KICAD_CLI_CANDIDATES: tuple[Path, ...] = ( Path.home() / "AppImages" / "kicad.appimage", Path("/usr/bin/kicad-cli"), @@ -113,17 +112,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: get_console().print( - f"[yellow] warn: kicad-cli {args[0]} upgrade timed out for {label}, skipping[/yellow]" + f"[yellow] warn: kicad-cli {args[0]} upgrade timed out for {label}, " + "skipping[/yellow]" ) return False except subprocess.CalledProcessError as exc: get_console().print( - f"[yellow] warn: kicad-cli {args[0]} upgrade failed for {label}: {exc.stderr.strip()}[/yellow]" + f"[yellow] warn: kicad-cli {args[0]} upgrade failed for {label}: " + f"{exc.stderr.strip()}[/yellow]" ) return False return True 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 33ead5a..4523536 100644 --- a/kicad_lib_manager/commands/import_zip/target.py +++ b/kicad_lib_manager/commands/import_zip/target.py @@ -1,33 +1,47 @@ """Resolving the libraries an import writes to from the KiLM configuration.""" +from dataclasses import dataclass from pathlib import Path import typer +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 -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 + +@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() @@ -46,8 +60,10 @@ def build_target( ) 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 ) @@ -134,7 +150,8 @@ def _pick_lib(candidates: list[Path], name: str | None, kind: str, option: str) if len(candidates) == 1: return candidates[0] get_console().print( - f"[red]Several {kind} libraries found, choose one with {option}: {available}[/red]" + f"[red]Several {kind} libraries found, choose one with {option}: " + f"{available}[/red]" ) raise typer.Exit(1) @@ -196,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/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/test_archive.py b/tests/import_zip/test_archive.py index 1b3f0e3..ad8cd72 100644 --- a/tests/import_zip/test_archive.py +++ b/tests/import_zip/test_archive.py @@ -9,9 +9,8 @@ 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: @@ -71,7 +70,7 @@ def test_zip_slip_entry_rejects_the_archive(tmp_path: Path) -> None: 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() diff --git a/tests/import_zip/test_cli.py b/tests/import_zip/test_cli.py index 585d4b0..1ea8206 100644 --- a/tests/import_zip/test_cli.py +++ b/tests/import_zip/test_cli.py @@ -4,7 +4,7 @@ from pathlib import Path from unittest.mock import MagicMock -from .helpers import ( +from tests.import_zip.helpers import ( SAMPLE_SYM_LIB, run_import, samacsys_zip, diff --git a/tests/import_zip/test_formats.py b/tests/import_zip/test_formats.py index 6570e2f..2446ff8 100644 --- a/tests/import_zip/test_formats.py +++ b/tests/import_zip/test_formats.py @@ -7,8 +7,13 @@ 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 diff --git a/tests/import_zip/test_models.py b/tests/import_zip/test_models.py index e4bee46..b57b69b 100644 --- a/tests/import_zip/test_models.py +++ b/tests/import_zip/test_models.py @@ -6,8 +6,7 @@ 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"), diff --git a/tests/import_zip/test_symbols.py b/tests/import_zip/test_symbols.py index 1023496..a3f936f 100644 --- a/tests/import_zip/test_symbols.py +++ b/tests/import_zip/test_symbols.py @@ -2,15 +2,14 @@ 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: @@ -74,5 +73,5 @@ 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"): + 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..89f00ff 100644 --- a/tests/import_zip/test_writes.py +++ b/tests/import_zip/test_writes.py @@ -10,8 +10,7 @@ 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, From f6066a29fc1e182012aa5666798bd6f4ad311b3d Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:47:24 +0200 Subject: [PATCH 042/105] refactor(init): pass the strict ruff and ty gates Split init into folder sorting, the descriptions template, the folder report, and the success message, make its options keyword-only, and narrow the best-effort template write to OSError. Output is unchanged. --- kicad_lib_manager/commands/init/command.py | 242 +++++++++++---------- 1 file changed, 122 insertions(+), 120 deletions(-) diff --git a/kicad_lib_manager/commands/init/command.py b/kicad_lib_manager/commands/init/command.py index 604ba50..483bb00 100644 --- a/kicad_lib_manager/commands/init/command.py +++ b/kicad_lib_manager/commands/init/command.py @@ -1,5 +1,8 @@ -"""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 @@ -9,13 +12,100 @@ from rich.panel import Panel from rich.prompt import Confirm +from kicad_lib_manager.commands.kicad_common import as_json_object +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 ...services.config_service import Config -from ...services.library_service import LibraryService +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" +""" + +def _sort_folders( + capabilities: object, present_before: set[str] +) -> tuple[list[str], list[str]]: + """Split the library folders into those that existed and those just created. -def init( + ``capabilities`` maps folder names to whether the library has them, as + stored in the metadata; ``present_before`` names the directory entries + from before initialization. + """ + existing: list[str] = [] + created: list[str] = [] + for folder, present in (as_json_object(capabilities) or {}).items(): + if present: + (existing if folder in present_before else created).append(folder) + return existing, created + + +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 the folder status; exit when there are none and the user stops.""" + 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)}") + if existing or created: + return + 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) + + +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( @@ -75,8 +165,8 @@ def init( symbols and footprints, not for 3D model libraries. """ current_dir = Path.cwd().resolve() - - get_console().print( + console = get_console() + console.print( Panel( f"[bold blue]Initializing KiCad library[/bold blue]\n" f"[cyan]Location:[/cyan] {current_dir}", @@ -87,11 +177,8 @@ def init( # The service creates the library folders, so note what exists beforehand. present_before = {entry.name for entry in current_dir.iterdir()} - - # Use library service to initialize - library_service = LibraryService() try: - metadata = library_service.initialize_library( + metadata = LibraryService().initialize_library( directory=current_dir, name=name, description=description, @@ -99,122 +186,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: - if folder_type in present_before: - existing_folders.append(folder_type) - else: - created_folders.append(folder_type) - except Exception as e: - get_console().print(f"[red]Error initializing library: {e}[/red]") + 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) - get_console().print( - "[green]Created library_descriptions.yaml template file.[/green]" - ) - except Exception as e: - get_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: - get_console().print( - f"[blue]Found existing folders:[/blue] {', '.join(existing_folders)}" - ) - if created_folders: - get_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: - get_console().print( - "[yellow]Warning: No library folders were found or created.[/yellow]" - ) - if not Confirm.ask("Continue anyway?", default=True): - get_console().print("[yellow]Initialization cancelled.[/yellow]") - raise typer.Exit(0) + _write_descriptions_template(current_dir) + _report_folders(*_sort_folders(metadata.get("capabilities"), 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]" - ) - - get_console().print( - Panel( - success_content, - title="✅ Initialization Complete", - border_style="green", - ) - ) - - # Add a hint for adding 3D models - get_console().print("\n[bold]Next Steps:[/bold]") - get_console().print( - "To add a 3D models directory (typically stored in the cloud), use:" - ) - get_console().print( - "[dim] kilm add-3d --name my-3d-models --directory /path/to/3d/models[/dim]" - ) - except Exception as e: - get_console().print(f"[red]Error initializing library: {e}[/red]") + 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]" + ) From 329a7a79f489687dc6b169db69c570067be2f656 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:48:02 +0200 Subject: [PATCH 043/105] refactor(update): split the update service and pass ruff ALL and strict ty services/update_service.py mixed installation detection, the PyPI client, installer commands, and Rich rendering behind two layers that mostly forwarded to each other. It is now three modules: - install_method: InstallMethod, InstallationDetector, detect_installation_method, upgrade_command, update_instruction, run_upgrade. - pypi_version: PyPIVersionChecker with a typed CachedVersion entry. - update_service: UpdateService (UpdateManager merged into it) with a typed UpdateStatus, an UpdateResult, and an injectable method and LatestVersionSource, so tests no longer patch detection or Rich. UpdateService.perform_update now only installs; kilm update perform decides whether an update is needed and renders the dry-run plan. The command modules use absolute imports, keyword-only Typer options, and plain-text status words instead of emoji and check-mark glyphs. UpdateService(version), check_for_updates(use_cache=...) and show_update_notification(quiet=...) keep the shape main.py uses; the boolean parameters are keyword-only. The tests drive the registered update_app against a simulated installation and are split along the same lines as the modules. --- kicad_lib_manager/commands/update/__init__.py | 10 +- kicad_lib_manager/commands/update/check.py | 64 +- kicad_lib_manager/commands/update/command.py | 13 +- kicad_lib_manager/commands/update/info.py | 62 +- kicad_lib_manager/commands/update/perform.py | 177 +++-- kicad_lib_manager/services/install_method.py | 210 ++++++ kicad_lib_manager/services/pypi_version.py | 140 ++++ kicad_lib_manager/services/update_service.py | 564 +++----------- tests/test_install_method.py | 253 +++++++ tests/test_pypi_version.py | 121 ++++ tests/test_update_commands.py | 666 +++++------------ tests/test_update_service.py | 685 ++++-------------- 12 files changed, 1284 insertions(+), 1681 deletions(-) create mode 100644 kicad_lib_manager/services/install_method.py create mode 100644 kicad_lib_manager/services/pypi_version.py create mode 100644 tests/test_install_method.py create mode 100644 tests/test_pypi_version.py diff --git a/kicad_lib_manager/commands/update/__init__.py b/kicad_lib_manager/commands/update/__init__.py index 1a8ab38..53ad7b4 100644 --- a/kicad_lib_manager/commands/update/__init__.py +++ b/kicad_lib_manager/commands/update/__init__.py @@ -1,9 +1,9 @@ -"""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__ = [ "check_update_command", diff --git a/kicad_lib_manager/commands/update/check.py b/kicad_lib_manager/commands/update/check.py index f682824..f38ff12 100644 --- a/kicad_lib_manager/commands/update/check.py +++ b/kicad_lib_manager/commands/update/check.py @@ -1,16 +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 kicad_lib_manager.services.update_service import UpdateService from kicad_lib_manager.utils.console import get_console -from ...services.update_service import UpdateService - def check_update_command( + *, force: Annotated[ bool, typer.Option(help="Force fresh check, bypass cache") ] = False, @@ -19,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"] - - get_console().print("[yellow]Update available![/yellow]") - get_console().print(f"Current version: [blue]{current}[/blue]") - get_console().print(f"Latest version: [green]{latest}[/green]") - get_console().print(f"Installation method: [cyan]{method}[/cyan]") - - if can_auto_update: - get_console().print( - "\nRun [bold cyan]kilm update perform[/bold cyan] to update" + """Check for available updates without installing. + + 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 not status["has_update"]: + if not quiet: + console.print( + "[green]You are using the latest version[/green]" + f" ([bold cyan]v{status['current_version']}[/bold cyan])" ) - else: - update_cmd = update_service.get_update_instructions() - get_console().print(f"\nManual update required: [cyan]{update_cmd}[/cyan]") - - # Exit with code 1 to indicate update available - raise typer.Exit(1) - if not quiet: - get_console().print( - f"[green]✓[/green] You are using the latest version ([bold cyan]v{update_info['current_version']}[/bold cyan])" - ) - # Exit with code 0 for no update needed - raise typer.Exit(0) + 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 66dac9a..37a94ce 100644 --- a/kicad_lib_manager/commands/update/command.py +++ b/kicad_lib_manager/commands/update/command.py @@ -1,19 +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/info.py b/kicad_lib_manager/commands/update/info.py index 401bd14..fd5ce1c 100644 --- a/kicad_lib_manager/commands/update/info.py +++ b/kicad_lib_manager/commands/update/info.py @@ -1,4 +1,4 @@ -"""Update info command implementation.""" +"""`kilm update info`: show how KiLM is installed and how it updates.""" import importlib.metadata from typing import Annotated @@ -6,55 +6,47 @@ import typer from rich.panel import Panel +from kicad_lib_manager.services.update_service import UpdateService from kicad_lib_manager.utils.console import get_console -from ...services.update_service import UpdateService - def info_command( + *, force_check: Annotated[ bool, typer.Option(help="Force fresh version check, bypass cache") ] = False, ) -> None: """Show detailed installation and update information.""" - 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() + update_service = UpdateService(importlib.metadata.version("kilm")) + status = update_service.check_for_updates(use_cache=not force_check) - # 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 + 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 3d0d633..4f4cdd1 100644 --- a/kicad_lib_manager/commands/update/perform.py +++ b/kicad_lib_manager/commands/update/perform.py @@ -1,4 +1,4 @@ -"""Update perform command implementation.""" +"""`kilm update perform`: install a new KiLM release with its own installer.""" import importlib.metadata from typing import Annotated @@ -7,12 +7,19 @@ from packaging.version import InvalidVersion, Version from rich.panel import Panel +from kicad_lib_manager.services.update_service import UpdateService from kicad_lib_manager.utils.console import get_console -from ...services.update_service import UpdateService +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( @@ -40,97 +47,111 @@ def perform_update_command( version from PyPI. The update method depends on how KiLM was installed (pip, pipx, uv tool, etc.). - ⚠️ DEPRECATION NOTICE: + 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. """ - # 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." - ) - get_console().print(Panel(deprecation_notice, expand=False, border_style="yellow")) - - version = importlib.metadata.version("kilm") + console = get_console() + console.print(Panel(BREAKING_CHANGE_NOTICE, expand=False, border_style="yellow")) if target_version is not None: - try: - Version(target_version) - except InvalidVersion: - raise typer.BadParameter( - f"'{target_version}' is not a version number", - param_hint="--target-version", - ) from None - update_service = UpdateService(version) + _validate_version(target_version) + update_service = UpdateService(importlib.metadata.version("kilm")) if dry_run: - get_console().print( - "[yellow]Dry run mode - showing what would be done[/yellow]" - ) - get_console().print() - + 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(): - get_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() - - get_console().print(f"Installation method: [bold]{method}[/bold]") - get_console().print(f"Update manually: [bold cyan]{update_cmd}[/bold cyan]") + 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) + + 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" + ) - if target_version is not None and not force and not dry_run: - if Version(target_version) == Version(version): - get_console().print( - f"[green]✓[/green] Already using version [bold cyan]{version}[/bold cyan]" - ) - raise typer.Exit(0) - get_console().print( - f"Updating from [blue]{version}[/blue] to [green]{target_version}[/green]" - ) - get_console().print() - elif not force and not dry_run: - update_info = update_service.check_for_updates(use_cache=not no_cache) - if not update_info["has_update"]: - get_console().print( - f"[green]✓[/green] Already using the latest version ([bold cyan]v{update_info['current_version']}[/bold cyan])" - ) - get_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"] - get_console().print( - f"Updating from [blue]{current}[/blue] to [green]{latest}[/green]" - ) - get_console().print() +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 - # 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: - get_console().print( - "\n[green]✓[/green] Dry run completed - automatic update is supported" + +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( + f"[green]Already using version[/green] [bold cyan]{current}[/bold cyan]" ) raise typer.Exit(0) - get_console().print("\n[red]✗[/red] Automatic update not available") - get_console().print("Manual update will be required") - raise typer.Exit(1) + 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: - get_console().print("\n[green]✓[/green] Update completed successfully!") - get_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: - get_console().print(f"\n[red]✗[/red] Update failed: {message}") - raise typer.Exit(1) + ) + get_console().print("\nDry run completed - automatic update is supported") 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/pypi_version.py b/kicad_lib_manager/services/pypi_version.py new file mode 100644 index 0000000..8bdecf3 --- /dev/null +++ b/kicad_lib_manager/services/pypi_version.py @@ -0,0 +1,140 @@ +"""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 + +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.""" + + +def default_cache_file() -> Path: + """Return where the version check is cached for the current user.""" + return Path.home() / ".cache" / "kilm" / "version_check.json" + + +@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 default_cache_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 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 + info = response.json().get("info") + 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 b750c9a..dc40537 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -1,520 +1,172 @@ -"""Auto-update functionality for KiLM. -Handles installation method detection, PyPI integration, and update execution. -""" +"""Checking for and installing new KiLM releases.""" # PEP 810: on Python 3.15+ these imports load on first use. -__lazy_modules__ = {"packaging.version", "requests"} +__lazy_modules__ = {"packaging.version"} -import importlib.metadata -import json -import os -import subprocess -import sys -import time -from pathlib import Path, PurePath -from typing import Any, Literal +from typing import NamedTuple, Protocol, TypedDict -import requests from packaging.version import InvalidVersion, Version +from rich.panel import Panel +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 -InstallMethod = Literal[ - "pipx", "conda", "uv", "nix", "pip-venv", "homebrew", "pip", "unknown" -] -"""How KiLM was installed; decides how it can be updated.""" +class LatestVersionSource(Protocol): + """Where the latest release is looked up; `PyPIVersionChecker` in production.""" -def read_installer() -> str | None: - """Return the installer recorded for kilm (INSTALLER file), if any.""" - try: - installer = importlib.metadata.distribution("kilm").read_text("INSTALLER") - except importlib.metadata.PackageNotFoundError: - return None - return installer.strip() if installer else None - - -class InstallationDetector: - """Platform-aware installation method detector.""" - - def __init__( - self, - executable_path: PurePath | None = None, - platform_name: str | None = None, - *, - installer: str | None = "", - ): - self.executable_path = executable_path or Path(sys.executable) - self.platform_name = platform_name or os.name - # "" means "read it from the installed distribution". - self.installer = read_installer() if installer == "" else installer - # 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 a conda install: 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 - prefix = os.environ.get("CONDA_PREFIX") - return bool(prefix) and self.executable_path.is_relative_to( - type(self.executable_path)(prefix) - ) - - def detect_uv(self) -> bool: - """Detect a uv tool install: 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 - tool_dir = os.environ.get("UV_TOOL_DIR") - return bool(tool_dir) and self.executable_path.is_relative_to( - type(self.executable_path)(tool_dir) - ) - - 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_nix(self) -> bool: - """Detect a Nix install: the interpreter lives in the Nix store.""" - return self.executable_str.startswith("/nix/store/") - - def detect(self) -> InstallMethod: - """Detect 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. - """ - # Priority order matters - more specific first - if self.detect_pipx(): - return "pipx" - - if self.detect_conda(): - return "conda" + def check_latest_version(self, *, use_cache: bool = True) -> str | None: + """Return the latest released version, or None when it is unknown.""" + ... - if self.detect_uv(): - return "uv" - if self.detect_nix(): - return "nix" +class UpdateStatus(TypedDict): + """Result of an update check.""" - if self.detect_virtual_env(): - return "pip-venv" + has_update: bool + """Whether a release newer than the installed version exists.""" + current_version: str + latest_version: str | None + """None when PyPI could not be asked and nothing was cached.""" + method: InstallMethod + supports_auto_update: bool - if self.detect_homebrew(): - return "homebrew" - if self.installer == "pip": - return "pip" +class UpdateResult(NamedTuple): + """Outcome of an installation attempt.""" - return "unknown" + success: bool + message: str + """On failure, why it failed and how to update by hand.""" -def detect_installation_method() -> InstallMethod: - """Detect how KiLM was installed to determine appropriate update strategy.""" - detector = InstallationDetector() - return detector.detect() +def is_newer_version(latest_version: str, current_version: str) -> bool: + """Return whether `latest_version` is newer; False if either is no version.""" + try: + return Version(latest_version) > Version(current_version) + except InvalidVersion: + return False -class PyPIVersionChecker: - """PyPI client for the latest release of a package, cached for a day. +class UpdateService: + """Checks PyPI for new KiLM releases and installs them. - 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. + The installation method is detected once, at construction, and decides + which installer command an update runs. """ - CACHE_TTL_SECONDS = 24 * 60 * 60 - def __init__( self, - package_name: str, - version: str = "unknown", + current_version: str, *, - cache_file: Path | None = None, - ): - self.package_name = package_name - self.base_url = f"https://pypi.org/pypi/{package_name}/json" - self.cache_file = cache_file or ( - Path.home() / ".cache" / "kilm" / "version_check.json" - ) - 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. + method: InstallMethod | None = None, + version_checker: LatestVersionSource | None = None, + ) -> None: + """Prepare update operations for the running installation. - With `use_cache` a fresh cached answer is returned without a request. - Network and response errors return None and never raise, so an - offline machine only loses the update notice. + 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. """ - cached_data = self._load_cache() - cached_version = cached_data.get("version") if cached_data else None - if use_cache and cached_data and self._is_fresh(cached_data): - return cached_version - - headers = {"User-Agent": self.user_agent} - if cached_data and cached_data.get("etag"): - headers["If-None-Match"] = cached_data["etag"] - try: - response = requests.get(self.base_url, headers=headers, timeout=10) - if response.status_code == 304: # Not Modified - latest_version = cached_version - etag = cached_data.get("etag") if cached_data else None - elif response.status_code == 200: - latest_version = response.json()["info"]["version"] - etag = response.headers.get("ETag") - else: - return None - except (requests.RequestException, KeyError, ValueError): - # Fail silently - don't block CLI functionality - return None - - if latest_version: - self._save_cache( - {"version": latest_version, "etag": etag, "timestamp": time.time()} - ) - return latest_version - - def _is_fresh(self, data: dict) -> bool: - return time.time() - data.get("timestamp", 0) < self.CACHE_TTL_SECONDS - - def _load_cache(self) -> dict | None: - """Load cached version data of any age; None when absent or unreadable.""" - try: - with self.cache_file.open() as f: - data = json.load(f) - except (OSError, ValueError): - return None - return data if isinstance(data, dict) else None - - def _save_cache(self, data: dict): - """Save version data to cache; a cache that cannot be written is skipped.""" - try: - self.cache_file.parent.mkdir(parents=True, exist_ok=True) - with self.cache_file.open("w") as f: - json.dump(data, f) - except OSError: - pass - - -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. - """ - spec = f"kilm=={target_version}" if target_version else "kilm" - if method == "pipx": - if target_version: - return ["pipx", "install", "--force", spec] - 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. - return ["uv", "tool", "install", spec if target_version else "kilm@latest"] - if method in ("pip", "pip-venv"): - return ["pip", "install", "--upgrade", spec] - return None - - -UPGRADE_TIMEOUT_SECONDS = 300 -"""How long an installer may run before the update counts as failed.""" - -UPGRADE_ERROR_LINES = 10 -"""Trailing lines of installer output kept to explain a failed update.""" - - -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( - 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}" - - -class UpdateManager: - """Manages update checking and execution for KiLM.""" - - def __init__(self, current_version: str): - self.version_checker = PyPIVersionChecker("kilm", current_version) self.current_version = current_version - self.installation_method: InstallMethod = detect_installation_method() - - def check_latest_version(self, *, use_cache: bool = True) -> str | None: - """Return the latest kilm release, from a fresh cache unless bypassed.""" - return self.version_checker.check_latest_version(use_cache=use_cache) - - 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, target_version: str | None = None) -> str: - """Get update instruction for the detected installation method.""" - command = upgrade_command(self.installation_method, target_version) - if command: - return " ".join(command) - instructions: 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", - } - return instructions.get(self.installation_method, "") + 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 upgrade_command(self.installation_method) is not None + """Whether KiLM can run the installer for this installation method.""" + return upgrade_command(self.method) is not None - def perform_update(self, target_version: str | None = None) -> tuple[bool, str]: - """Install the latest release, or `target_version` when given. + 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) - Returns: (success, message) - """ - command = upgrade_command(self.installation_method, target_version) - if command is None: - instruction = self.get_update_instruction() - return False, f"Manual update required: {instruction}" - error = run_upgrade(command) - if error is None: - return True, "KiLM updated successfully!" - instruction = self.get_update_instruction(target_version) - return False, f"{error}\nTry manually: {instruction}" - - -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]: - """Check for available updates. + def check_for_updates(self, *, use_cache: bool = True) -> UpdateStatus: + """Compare the installed version with the latest release on PyPI. - 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 + With `use_cache` a version check from the last day is reused. Never + raises for network problems; `latest_version` is then None. """ - latest_version = self.manager.check_latest_version(use_cache=use_cache) - 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[/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"Update manually: [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) -> InstallMethod: - """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) - - # A target version is installed as asked, even when it is older. - if not force and target_version is None: - update_info = self.check_for_updates() - if not update_info["has_update"]: - return True, "Already using the latest version!" - - return self.manager.perform_update(target_version) - - 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(target_version) - - 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/tests/test_install_method.py b/tests/test_install_method.py new file mode 100644 index 0000000..b67bc61 --- /dev/null +++ b/tests/test_install_method.py @@ -0,0 +1,253 @@ +"""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 inspects the running interpreter.""" + monkeypatch.setattr(sys, "executable", PIPX_PYTHON) + assert detect_installation_method() == "pipx" + + +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_pypi_version.py b/tests/test_pypi_version.py new file mode 100644 index 0000000..f1af03f --- /dev/null +++ b/tests/test_pypi_version.py @@ -0,0 +1,121 @@ +"""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 + + +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_update_commands.py b/tests/test_update_commands.py index 9fb5290..bc42cf4 100644 --- a/tests/test_update_commands.py +++ b/tests/test_update_commands.py @@ -1,567 +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, - } - - result = runner.invoke(check_app, ["--force"]) + 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 - 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 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_dry_run_unsupported_method_exits_nonzero( - self, runner, mock_update_service, mock_version - ): - """A dry run fails like the real run when automatic update is impossible.""" - 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, ["--dry-run"]) - - assert result.exit_code == 1 - assert "Automatic update not supported" in result.stdout - mock_update_service.perform_update.assert_not_called() - - def test_perform_target_version_when_up_to_date( - self, runner, mock_update_service, mock_version - ): - """A target version installs even when no newer release exists.""" - 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", - } - mock_update_service.perform_update.return_value = (True, "done") - - result = runner.invoke(perform_app, ["--target-version", "0.9.0"]) + 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 assert "Updating from 1.0.0 to 0.9.0" in result.stdout - mock_update_service.perform_update.assert_called_with( - target_version="0.9.0", force=False, dry_run=False - ) + assert installation.upgrades == [["pipx", "install", "--force", "kilm==0.9.0"]] - def test_perform_target_version_already_installed( - self, runner, mock_update_service, mock_version - ): + def test_target_version_already_installed( + self, installation: FakeInstallation + ) -> None: """Asking for the installed version changes nothing without --force.""" - mock_update_service.can_auto_update.return_value = True - - result = runner.invoke(perform_app, ["--target-version", "1.0.0"]) - + result = kilm_update("perform", "-t", "1.0") assert result.exit_code == 0 assert "Already using version 1.0.0" in result.stdout - mock_update_service.perform_update.assert_not_called() - - def test_perform_rejects_invalid_target_version( - self, runner, mock_update_service, mock_version - ): - """A target that is not a version is a usage error, not an install.""" - mock_update_service.can_auto_update.return_value = True - - result = runner.invoke(perform_app, ["--target-version", ">=2"]) + assert installation.upgrades == [] + 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 - mock_update_service.perform_update.assert_not_called() + assert installation.upgrades == [] - 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", - } + 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] - result = runner.invoke(perform_app, ["--no-cache"]) - 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 "BREAKING CHANGE NOTICE" in result.stdout - assert "kilm sync" in result.stdout +class TestGroup: + """The `kilm update` group itself.""" - -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"]) - - 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 - - def test_update_app_no_args_shows_help(self, runner): - """Test that running update with no args shows help.""" - result = runner.invoke(update_app, []) + for name in ("check", "info", "perform"): + assert name in result.stdout - # 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 8248bd9..3be2631 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -1,581 +1,148 @@ -"""Tests for the update service functionality including installation detection and update operations.""" - -import subprocess -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, - PyPIVersionChecker, - UpdateManager, - UpdateService, - detect_installation_method, -) - - -class TestInstallationDetection: - """Test installation method detection logic using the modular detector.""" - - @pytest.fixture(autouse=True) - def _clear_detection_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Keep detection hermetic: CI runners and dev shells may export these. - - 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", - "CONDA_PREFIX", - "UV_TOOL_DIR", - "UV_TOOL_BIN_DIR", - "VIRTUAL_ENV", - ): - monkeypatch.delenv(var, raising=False) - - 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") +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 - 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" +class StaticVersionSource: + """A latest-release source that answers without the network.""" - def test_detect_conda_installation_in_active_prefix(self, monkeypatch): - """An executable inside the active CONDA_PREFIX is a conda install.""" - monkeypatch.setenv("CONDA_PREFIX", "/opt/envs/kilm-env") + def __init__(self, latest: str | None) -> None: + """Answer every check with `latest`.""" + self.latest = latest + self.use_cache_calls: list[bool] = [] - detector = InstallationDetector( - executable_path=Path("/opt/envs/kilm-env/bin/python") - ) - assert detector.detect_conda() is True - assert detector.detect() == "conda" + 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_active_conda_env_alone_is_not_a_conda_install(self, monkeypatch): - """Many shells activate conda's base env, whatever installed kilm.""" - monkeypatch.setenv("CONDA_DEFAULT_ENV", "base") - monkeypatch.setenv("CONDA_PREFIX", "/opt/envs/base") - detector = InstallationDetector( - executable_path=Path("/Users/user/.local/share/uv/tools/kilm/bin/python") - ) - assert detector.detect_conda() is False - assert detector.detect() == "uv" - - def test_detect_uv_installation_in_custom_tool_dir(self, monkeypatch): - """An executable inside UV_TOOL_DIR is a uv tool install.""" - monkeypatch.setenv("UV_TOOL_DIR", "/custom/uv-tools") - - detector = InstallationDetector( - executable_path=Path("/custom/uv-tools/kilm/bin/python") - ) - assert detector.detect_uv() is True - assert detector.detect() == "uv" - - def test_uv_env_vars_alone_are_not_a_uv_install(self, monkeypatch): - """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") - - detector = InstallationDetector(executable_path=Path("/usr/bin/python")) - assert detector.detect_uv() is False - 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" - - @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", - ), - ( - r"C:\Users\user\AppData\Roaming\uv\data\tools\kilm\Scripts\python.exe", - "uv", - ), - ], +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) ) - 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_detect_nix_installation(self, monkeypatch): - """Interpreters in the Nix store are Nix installs, even inside a venv.""" - monkeypatch.setenv("VIRTUAL_ENV", "/home/user/project/.venv") - - detector = InstallationDetector( - executable_path=Path("/nix/store/abc123-python3-3.12.9/bin/python3.12") - ) - assert detector.detect() == "nix" - - def test_system_python_installed_by_pip(self, monkeypatch): - """Outside any environment, pip's INSTALLER record identifies pip.""" - monkeypatch.setattr(sys, "prefix", sys.base_prefix) - - detector = InstallationDetector( - executable_path=Path("/usr/bin/python3"), installer="pip" - ) - assert detector.detect() == "pip" - - def test_unrecognized_installation_is_unknown(self, monkeypatch): - """Without evidence of an installer, detection does not guess pip.""" - monkeypatch.setattr(sys, "prefix", sys.base_prefix) - - detector = InstallationDetector( - executable_path=Path("/usr/bin/python3"), installer=None - ) - assert detector.detect() == "unknown" - - 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_legacy_detect_installation_method_function(self, monkeypatch): - """Test that the legacy function still works.""" - monkeypatch.setattr( - sys, "executable", "/Users/user/.local/share/pipx/venvs/kilm/bin/python" - ) - 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_version_checks_identify_the_installed_version(self, update_manager): - """PyPI requests carry the running kilm version in the User-Agent.""" - assert update_manager.version_checker.user_agent.startswith("KiLM/1.0.0 ") - - 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", "nix", "unknown"]: - 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 install kilm@latest"), - ("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"), - ] - 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 +@pytest.fixture +def upgrades(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]: + """Record installer commands instead of running them; they succeed.""" + commands: list[list[str]] = [] -class TestUpdateService: - """Test UpdateService functionality.""" + def fake_run_upgrade(command: list[str]) -> str | None: + commands.append(command) + return None - @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") + monkeypatch.setattr(update_service, "run_upgrade", fake_run_upgrade) + return commands - 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() +class TestCheckForUpdates: + """Comparing the installed version with the latest release.""" @pytest.mark.parametrize( - ("method", "target_version", "expected_argv"), - [ - ("pipx", None, ["pipx", "upgrade", "kilm"]), - ("pipx", "1.0.5", ["pipx", "install", "--force", "kilm==1.0.5"]), - ("uv", None, ["uv", "tool", "install", "kilm@latest"]), - ("uv", "1.0.5", ["uv", "tool", "install", "kilm==1.0.5"]), - ("pip", None, [sys.executable, "-m", "pip", "install", "--upgrade", "kilm"]), - ( - "pip-venv", - "1.0.5", - [sys.executable, "-m", "pip", "install", "--upgrade", "kilm==1.0.5"], - ), - ], + ("latest", "has_update"), + [("1.1.0", True), ("1.0.0", False), ("0.9.0", False), (None, False)], ) - def test_perform_update_installs_the_target_version( - self, monkeypatch, method, target_version, expected_argv - ): - """The installer receives the requested version, even a downgrade.""" - with patch( - "kicad_lib_manager.services.update_service.detect_installation_method", - return_value=method, - ): - service = UpdateService("1.0.0") - calls = [] - - def fake_run(argv, **_kwargs): - calls.append(argv) - return subprocess.CompletedProcess(argv, 0, "", "") - - monkeypatch.setattr( - "kicad_lib_manager.services.update_service.subprocess.run", fake_run - ) - # Already up to date: only a target version (or --force) installs. - with patch.object(service, "check_for_updates") as mock_check: - mock_check.return_value = {"has_update": False} - success, _ = service.perform_update( - target_version=target_version, force=target_version is None - ) - - assert success is True - assert calls == [expected_argv] - - def test_failed_update_reports_the_installer_error(self, monkeypatch): - """The installer's error output explains why an update failed.""" - with patch( - "kicad_lib_manager.services.update_service.detect_installation_method", - return_value="pipx", - ): - service = UpdateService("1.0.0") + 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 fake_run(argv, **_kwargs): - error = "No matching distribution found for kilm==9.9.9\n" - return subprocess.CompletedProcess(argv, 1, "", error) + 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( - "kicad_lib_manager.services.update_service.subprocess.run", fake_run - ) - - success, message = service.perform_update(target_version="9.9.9") - - assert success is False - assert "No matching distribution found for kilm==9.9.9" in message - assert "pipx install --force kilm==9.9.9" in message - - 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, tmp_path): - """A checker whose cache lives in the test's temporary directory.""" - return PyPIVersionChecker( - "kilm", "1.0.0", cache_file=tmp_path / "version_check.json" - ) - - @staticmethod - def pypi_response(version: str = "1.1.0") -> Mock: - """A successful PyPI JSON API response for `version`.""" - response = Mock() - response.status_code = 200 - response.json.return_value = {"info": {"version": version}} - response.headers.get.return_value = "etag123" - return response - - @patch("requests.get") - def test_successful_version_check(self, mock_get, version_checker): - """Test successful version check from PyPI.""" - mock_get.return_value = self.pypi_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_fresh_cache_answers_without_network(self, mock_get, version_checker): - """Within a day of the last check the cached version is the answer.""" - mock_get.return_value = self.pypi_response("1.1.0") - version_checker.check_latest_version() - mock_get.reset_mock() - - assert version_checker.check_latest_version() == "1.1.0" - mock_get.assert_not_called() - - @patch("requests.get") - def test_bypassing_the_cache_queries_pypi(self, mock_get, version_checker): - """use_cache=False asks PyPI even when the cache is fresh.""" - mock_get.return_value = self.pypi_response("1.1.0") - version_checker.check_latest_version() - mock_get.return_value = self.pypi_response("1.2.0") - - assert version_checker.check_latest_version(use_cache=False) == "1.2.0" - - @patch("requests.get") - def test_not_modified_response(self, mock_get, version_checker): - """A 304 answer keeps the cached version.""" - mock_get.return_value = self.pypi_response("1.0.5") - version_checker.check_latest_version() - not_modified = Mock() - not_modified.status_code = 304 - mock_get.return_value = not_modified - - assert version_checker.check_latest_version(use_cache=False) == "1.0.5" - assert mock_get.call_args.kwargs["headers"]["If-None-Match"] == "etag123" + update_service, + "run_upgrade", + lambda _command: "No matching distribution found for kilm==9.9.9", + ) + 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" + ) + + 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 "kilm 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 From ec4887a5f8a4c401a3ff74c8882a0257bcee7f3e Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:48:07 +0200 Subject: [PATCH 044/105] docs(update): describe the version check cache and installer failures --- kicad_lib_manager/commands/update/docs.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/kicad_lib_manager/commands/update/docs.mdx b/kicad_lib_manager/commands/update/docs.mdx index 68c9753..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 @@ -64,7 +66,7 @@ KiLM reports **unknown** when nothing identifies the installer, for example a di 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 instruction 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 From 9fa3c1286644fa194c1c7114d5328c6abc503563 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:48:39 +0200 Subject: [PATCH 045/105] refactor(add-3d): pass the strict ruff and ty gates Split add-3d into updating existing metadata, creating new metadata, the model check, and the report, make its options keyword-only, and count the models with one scan instead of two. The final report now reads the name and variable from the saved metadata, so --env-var together with --no-env-var no longer announces a variable that was not saved. Remove a try/except around read_cloud_metadata, which handles its own errors. --- kicad_lib_manager/commands/add_3d/command.py | 368 ++++++++++--------- 1 file changed, 193 insertions(+), 175 deletions(-) diff --git a/kicad_lib_manager/commands/add_3d/command.py b/kicad_lib_manager/commands/add_3d/command.py index 3fa5bf9..cb12ac6 100644 --- a/kicad_lib_manager/commands/add_3d/command.py +++ b/kicad_lib_manager/commands/add_3d/command.py @@ -1,14 +1,19 @@ -"""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 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 ...services.config_service import Config -from ...utils.metadata import ( +from kicad_lib_manager.utils.metadata import ( CLOUD_METADATA_FILE, generate_env_var_name, get_default_cloud_metadata, @@ -16,8 +21,165 @@ write_cloud_metadata, ) +MODEL_EXTENSIONS = (".step", ".stp", ".wrl", ".wings") + +CloudMetadata = dict[str, str | int | None] + + +@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 _update_existing( + directory: Path, metadata: CloudMetadata, overrides: _Overrides +) -> CloudMetadata: + """Apply ``overrides`` to the directory's metadata and save any change.""" + console = get_console() + console.print( + f"[green]Found existing metadata file[/green] ({CLOUD_METADATA_FILE})" + ) + name = metadata.get("name") + description = metadata.get("description") + env_var = metadata.get("env_var") + console.print(f"Using existing name: [blue]{name}[/blue]") + if _present(env_var) and not overrides.no_env_var: + console.print( + f"Using existing environment variable: [yellow]{env_var}[/yellow]" + ) + + if overrides.name is not None: + name = overrides.name + console.print(f"Overriding with provided name: [blue]{name}[/blue]") + if overrides.description is not None: + description = overrides.description + console.print( + f"Overriding with provided description: [blue]{description}[/blue]" + ) + if overrides.env_var is not None: + env_var = overrides.env_var + console.print( + f"Overriding with provided environment variable: [yellow]{env_var}[/yellow]" + ) + elif overrides.no_env_var: + console.print("[yellow]Disabling environment variable as requested[/yellow]") + + if overrides.given(): + metadata["name"] = name + metadata["description"] = description + metadata["env_var"] = ( + env_var if _present(env_var) and not overrides.no_env_var else None + ) + metadata["updated_with"] = "kilm" + write_cloud_metadata(directory, metadata) + console.print("[green]Updated metadata file with new information[/green]") + return metadata + + +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 _count_models(directory: Path) -> int: + """Return the number of 3D model files below ``directory``.""" + return sum( + len(list(directory.glob(f"**/*{extension}"))) for extension in MODEL_EXTENSIONS + ) + + +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( + +def add_3d( # noqa: PLR0913 -- each CLI option is a Typer parameter + *, name: Annotated[ str | None, typer.Option( @@ -68,188 +230,44 @@ 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() - - get_console().print( + 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: - get_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") - get_console().print(f"Using existing name: [blue]{library_name}[/blue]") - - # Show environment variable if present - if library_env_var and not no_env_var: - get_console().print( - f"Using existing environment variable: [yellow]{library_env_var}[/yellow]" - ) - - # Override with command line parameters if provided - if name: - library_name = name - get_console().print( - f"Overriding with provided name: [blue]{library_name}[/blue]" - ) - - if description: - library_description = description - get_console().print( - f"Overriding with provided description: [blue]{library_description}[/blue]" - ) - - if env_var: - library_env_var = env_var - get_console().print( - f"Overriding with provided environment variable: [yellow]{library_env_var}[/yellow]" - ) - elif no_env_var: - library_env_var = None - get_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) - get_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(directory) + has_existing = existing is not None and len(existing) > 0 + if existing is not None and has_existing and not force: + metadata = _update_existing(directory, existing, overrides) else: - # Create a new metadata file - if metadata and force: - get_console().print( - f"[yellow]Overwriting existing metadata file[/yellow] ({CLOUD_METADATA_FILE})" - ) - else: - get_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) - get_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: - get_console().print( - "[yellow]Warning: No 3D model files found in this directory[/yellow]" - ) - if not typer.confirm("Continue anyway?", default=True): - get_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_models(directory) + _confirm_models(model_count) metadata["model_count"] = model_count write_cloud_metadata(directory, metadata) - # 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(library_name, str(directory), "cloud") + console.print( + f"[bold green]3D models directory '{library_name}' added " + "successfully![/bold green]" ) - config.add_library(final_library_name, str(directory), "cloud") - - get_console().print( - f"[bold green]3D models directory '{final_library_name}' added successfully![/bold green]" - ) - get_console().print(f"[blue]Path:[/blue] {directory}") + console.print(f"[blue]Path:[/blue] {directory}") if model_count > 0: - get_console().print(f"[green]Found {model_count} 3D model files[/green]") - - if library_env_var: - get_console().print( - f"[yellow]Assigned environment variable:[/yellow] {library_env_var}" - ) - get_console().print("\n[bold]You can use this directory with:[/bold]") - get_console().print( - f" [cyan]kilm setup --threed-lib-dirs '{library_name}'[/cyan]" - ) - get_console().print(" [dim]# or by setting the environment variable[/dim]") - get_console().print( - f" [cyan]export {library_env_var}='{directory}'[/cyan]" - ) - - # Show current cloud libraries - libraries = config.get_libraries("cloud") - if len(libraries) > 1: - get_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: - get_console().print( - f" - [cyan]{lib_name}[/cyan]: {lib_path} [yellow](ENV: {lib_env_var})[/yellow]" - ) - else: - get_console().print(f" - [cyan]{lib_name}[/cyan]: {lib_path}") + console.print(f"[green]Found {model_count} 3D model files[/green]") + _print_usage(library_name, directory, metadata.get("env_var")) + _print_cloud_libraries(config) except Exception as e: - get_console().print(f"[red]Error adding 3D models directory: {e}[/red]") + console.print(f"[red]Error adding 3D models directory: {e}[/red]") raise typer.Exit(1) from e From ef765179b402545d0c3384c98daa9314f5b19fa2 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:48:41 +0200 Subject: [PATCH 046/105] test: bring the import command tests up to the strict lint rules Tests state their contract in a docstring, request fixtures they only need for side effects through usefixtures, and import write_atomically at module level. --- tests/import_zip/__init__.py | 1 + tests/import_zip/test_archive.py | 16 +++++---- tests/import_zip/test_cli.py | 54 +++++++++++++++++----------- tests/import_zip/test_formats.py | 25 ++++++++----- tests/import_zip/test_models.py | 60 +++++++++++++++++++++++--------- tests/import_zip/test_symbols.py | 9 +++++ tests/import_zip/test_writes.py | 24 ++++++++----- 7 files changed, 131 insertions(+), 58 deletions(-) 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/test_archive.py b/tests/import_zip/test_archive.py index ad8cd72..ebbcfbb 100644 --- a/tests/import_zip/test_archive.py +++ b/tests/import_zip/test_archive.py @@ -4,7 +4,6 @@ import zipfile from collections.abc import Callable from pathlib import Path -from unittest.mock import MagicMock import pytest @@ -66,6 +65,7 @@ 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() @@ -76,6 +76,7 @@ def test_zip_slip_entry_rejects_the_archive(tmp_path: Path) -> None: 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", "") @@ -92,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") @@ -112,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"): @@ -131,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 1ea8206..b36400a 100644 --- a/tests/import_zip/test_cli.py +++ b/tests/import_zip/test_cli.py @@ -4,6 +4,8 @@ from pathlib import Path from unittest.mock import MagicMock +import pytest + from tests.import_zip.helpers import ( SAMPLE_SYM_LIB, run_import, @@ -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 2446ff8..477e558 100644 --- a/tests/import_zip/test_formats.py +++ b/tests/import_zip/test_formats.py @@ -2,8 +2,9 @@ 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 @@ -95,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) @@ -114,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( @@ -133,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) @@ -161,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 b57b69b..7349796 100644 --- a/tests/import_zip/test_models.py +++ b/tests/import_zip/test_models.py @@ -1,7 +1,6 @@ """3D model placement and the model references written into footprints.""" from pathlib import Path -from unittest.mock import MagicMock import pytest @@ -39,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) @@ -49,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'} @@ -68,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" ) @@ -78,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", }, ) @@ -90,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", @@ -109,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"}) @@ -122,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"}) @@ -136,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") @@ -147,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", }, ) @@ -167,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", @@ -190,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"}) @@ -203,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() @@ -215,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"}) @@ -229,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( @@ -259,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( @@ -280,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( @@ -308,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 a3f936f..debf884 100644 --- a/tests/import_zip/test_symbols.py +++ b/tests/import_zip/test_symbols.py @@ -13,11 +13,13 @@ 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' @@ -29,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" @@ -36,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"] @@ -57,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") @@ -66,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 == [] @@ -73,5 +81,6 @@ def test_merge_symbols_treats_known_names_as_present() -> None: def test_merge_symbols_rejects_a_non_library_target() -> None: + """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 89f00ff..b26ad9c 100644 --- a/tests/import_zip/test_writes.py +++ b/tests/import_zip/test_writes.py @@ -5,7 +5,7 @@ import stat import sys from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -23,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, @@ -44,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, @@ -88,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() @@ -106,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" @@ -128,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) From 40b36c8f1f82c7dbc871ab3c94b9ccf1b79c9e58 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:49:07 +0200 Subject: [PATCH 047/105] refactor(add-hook): pass the strict ruff and ty gates Split add-hook into finding the hooks directory, preparing the replacement content, and installing the hook, and make its options keyword-only. Output is unchanged. --- .../commands/add_hook/command.py | 196 +++++++++--------- 1 file changed, 95 insertions(+), 101 deletions(-) diff --git a/kicad_lib_manager/commands/add_hook/command.py b/kicad_lib_manager/commands/add_hook/command.py index 6f26b1c..d472ae0 100644 --- a/kicad_lib_manager/commands/add_hook/command.py +++ b/kicad_lib_manager/commands/add_hook/command.py @@ -1,5 +1,8 @@ -"""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 @@ -8,17 +11,93 @@ import typer from kicad_lib_manager.utils.console import get_console - -from ...utils.file_ops import write_atomically -from ...utils.git_utils import ( +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, ) +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( @@ -38,103 +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 or Path.cwd() - - get_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) - get_console().print(f"[blue]Using hooks directory: {hooks_dir}[/blue]") - - except RuntimeError as e: - get_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: - get_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: - get_console().print("[yellow]Hook installation cancelled.[/yellow]") - return - - # Create backup of existing hook - backup_path = backup_existing_hook(post_merge_hook) - get_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 - get_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 - get_console().print( - "[cyan]Merging KiLM content with existing hook...[/cyan]" - ) - new_content = merge_hook_content( - existing_content, create_kilm_hook_content() - ) - - except (OSError, UnicodeDecodeError): - get_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: - # Binary write: text mode would give the shell script CRLF endings on Windows - write_atomically(post_merge_hook, new_content.encode("utf-8")) - - # Make the hook executable - post_merge_hook.chmod(0o755) - - get_console().print( - f"[green]Successfully installed post-merge hook at {post_merge_hook}[/green]" - ) - get_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: - get_console().print( - "\n[cyan]Note: The hook contains clear markers for KiLM-managed sections,[/cyan]" - ) - get_console().print( - "[cyan]making future updates safe and idempotent.[/cyan]" - ) - - get_console().print( - "\n[blue]Note: You may need to modify the hook script if you want to customize[/blue]" - ) - get_console().print( - "[blue]the update behavior or automatically set up libraries.[/blue]" - ) - + content = _content_replacing(hook, force=force) except Exception as e: - get_console().print(f"[red]Error creating hook: {e!s}[/red]") + get_console().print(f"[red]Error: {e}[/red]") raise typer.Exit(1) from e - - except typer.Exit: - raise - except Exception as e: - get_console().print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e + if content is None: + return + _install(hook, content) From f94d60ad5c69754698bffe4c5f49e669a6f40fd5 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:49:13 +0200 Subject: [PATCH 048/105] docs: give the backup name format with microseconds Backups are named with microseconds since the fix for #12. --- docs/src/content/docs/guides/configuration.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/docs/guides/configuration.mdx b/docs/src/content/docs/guides/configuration.mdx index 111024d..8848b6b 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**: From 00564390751d702425d3cf2682e95eedfd3e589b Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:50:40 +0200 Subject: [PATCH 049/105] refactor(status): pass the strict ruff and ty gates Read the KiLM configuration, kicad_common.json, and the legacy pinned file through the typed JSON helpers, share one table builder for the GitHub and cloud library tables and one printer for pinned lists, and narrow each section's error handling to the errors reading can raise. Drop the unused KiCadService argument of the pinned-library check and the handlers around the metadata readers, which catch their own errors. A configuration file that is not a mapping now reads as empty or invalid instead of failing with an AttributeError message. Output is otherwise unchanged (compared across six configurations). --- kicad_lib_manager/commands/status/command.py | 451 +++++++++---------- 1 file changed, 206 insertions(+), 245 deletions(-) diff --git a/kicad_lib_manager/commands/status/command.py b/kicad_lib_manager/commands/status/command.py index d60dccb..d98dc56 100644 --- a/kicad_lib_manager/commands/status/command.py +++ b/kicad_lib_manager/commands/status/command.py @@ -1,9 +1,13 @@ -"""Status command implementation for KiCad Library Manager (Typer version).""" +"""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. +""" # PEP 810: on Python 3.15+ these imports load on first use. __lazy_modules__ = {"yaml"} -import json +from collections.abc import Callable from pathlib import Path from typing import Annotated @@ -13,14 +17,30 @@ from rich.table import Table from rich.text import Text +from kicad_lib_manager.commands.kicad_common import ( + KICAD_COMMON_FILE, + PINNED_FOOTPRINTS_KEY, + PINNED_SYMBOLS_KEY, + JsonObject, + as_json_object, + as_list, + load_json_object, + pinned_names, + session_of, +) +from kicad_lib_manager.services.kicad_service import KiCadService from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.constants import CONFIG_DIR_NAME, CONFIG_FILE_NAME +from kicad_lib_manager.utils.metadata import read_cloud_metadata, read_github_metadata + +LEGACY_PINNED_FILE = "pinned" +LEGACY_PINNED_FOOTPRINTS_KEY = "pinned_footprint_libs" -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 +MetadataReader = Callable[[Path], object | None] def status( + *, verbose: Annotated[ bool, typer.Option( @@ -30,290 +50,231 @@ 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() - - get_console().print("\n[bold cyan]KiCad Configuration[/bold cyan]") - + console.print("\n[bold cyan]KiCad Configuration[/bold cyan]") kicad_config = kicad_service.find_kicad_config_dir() - get_console().print( - f"KiCad configuration directory: [blue]{kicad_config}[/blue]" - ) - - # Check environment variables in KiCad common + console.print(f"KiCad configuration directory: [blue]{kicad_config}[/blue]") _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) - except Exception as e: - get_console().print(f"[red]Error getting KiCad configuration: {e}[/red]") + 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(): - get_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: - get_console().print( - "[yellow]Configuration file is empty or invalid[/yellow]" - ) - - except Exception as e: - get_console().print(f"[red]Error reading configuration: {e}[/red]") - else: - get_console().print( - "[yellow]No KILM configuration file found. Run 'kilm init' to create one.[/yellow]" - ) - except Exception as e: - get_console().print(f"[red]Error reading KILM configuration: {e}[/red]") - - -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"): - get_console().print("[yellow] No libraries configured[/yellow]") + 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 - # Create tables for different library types - github_libs = [ - lib for lib in config_data["libraries"] if lib.get("type") == "github" - ] - cloud_libs = [lib for lib in config_data["libraries"] if lib.get("type") == "cloud"] - - 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) - - get_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 "" + 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) - # 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) +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 "", + "✓" if metadata is not None and metadata else "✗", + ) + get_console().print(table) - 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 + ] + if not libraries: + get_console().print("[yellow] No libraries configured[/yellow]") + return -def _show_kilm_settings(config_data: dict) -> None: - """Show KILM settings""" + current_library = config_data.get("current_library", "") + _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: - get_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(): - get_console().print("[yellow]No kicad_common.json found[/yellow]") + console.print("[yellow]No kicad_common.json found[/yellow]") return - try: - with kicad_common.open() as f: - common_config = json.load(f) - - get_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)) - - get_console().print(table) - else: - get_console().print("[yellow] No environment variables set[/yellow]") - else: - get_console().print("[yellow] No environment variables found[/yellow]") - except Exception as e: - get_console().print(f"[red]Error reading KiCad common configuration: {e}[/red]") - + 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 - get_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 - get_console().print("[cyan]Symbol Libraries:[/cyan]") - for lib in sym_libs: - get_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 - get_console().print("[cyan]Footprint Libraries:[/cyan]") - for lib in fp_libs: - get_console().print(f" • {lib}") - - if not found_pinned: - get_console().print( - "[yellow] No pinned libraries found in kicad_common.json[/yellow]" - ) - - return - except Exception as e: - get_console().print( + 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) - - get_console().print( - "\n[bold]Pinned Libraries in KiCad (legacy format):[/bold]" + 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", ) - found_pinned = False - - if "pinned_symbol_libs" in pinned_config: - sym_libs = pinned_config["pinned_symbol_libs"] - if sym_libs: - found_pinned = True - get_console().print("[cyan]Symbol Libraries:[/cyan]") - for lib in sym_libs: - get_console().print(f" • {lib}") - - if "pinned_footprint_libs" in pinned_config: - fp_libs = pinned_config["pinned_footprint_libs"] - if fp_libs: - found_pinned = True - get_console().print("[cyan]Footprint Libraries:[/cyan]") - for lib in fp_libs: - get_console().print(f" • {lib}") - - if not found_pinned: - get_console().print("[yellow] No pinned libraries found[/yellow]") - except Exception as e: - get_console().print(f"[red]Error reading pinned libraries: {e}[/red]") - else: - get_console().print("\n[yellow]No pinned libraries file found[/yellow]") + 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) - - get_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", "")) - - get_console().print(table) - else: - get_console().print("[yellow] No symbol libraries configured[/yellow]") - - get_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[dict[str, str]]) -> 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.get("name", ""), library.get("uri", "")) + console.print(table) - for lib in fp_libs: - table.add_row(lib.get("name", ""), lib.get("uri", "")) - get_console().print(table) - else: - get_console().print("[yellow] No footprint libraries configured[/yellow]") - except Exception as e: +def _show_configured_libraries(kicad_config: Path, kicad_service: KiCadService) -> None: + """Show the libraries in KiCad's symbol and footprint library tables.""" + try: + symbols, footprints = kicad_service.get_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) From 893fcd80f1bcbda5bbac70b0633fc836b3ed4f24 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:52:07 +0200 Subject: [PATCH 050/105] fix(sync): detect libraries renamed by a pull git diff --name-status prints a rename as "R100oldnew", which never matched the "R" status filter, so a renamed library was not reported as new. List added, modified, and renamed paths with --name-only --diff-filter=AMR, which prints a rename under its new name. --- kicad_lib_manager/commands/sync/command.py | 18 +++--------------- tests/test_sync_command.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/kicad_lib_manager/commands/sync/command.py b/kicad_lib_manager/commands/sync/command.py index ed9358f..dbc3edb 100644 --- a/kicad_lib_manager/commands/sync/command.py +++ b/kicad_lib_manager/commands/sync/command.py @@ -231,7 +231,7 @@ def check_for_library_changes(lib_path: Path, since: str = "HEAD~1") -> list[str try: # Get the diff between HEAD~1 and HEAD to see what changed in the pull result = subprocess.run( - ["git", "diff", "--name-status", since, "HEAD"], + ["git", "diff", "--name-only", "--diff-filter=AMR", since, "HEAD"], cwd=lib_path, capture_output=True, text=True, @@ -247,20 +247,8 @@ def check_for_library_changes(lib_path: Path, since: str = "HEAD~1") -> list[str 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 - + # Added, modified, and renamed paths (a rename under its new name) + for path in diff_output.splitlines(): # Check for symbol library changes if _is_symbol_library_change(path) and "symbols" not in changes: changes.append("symbols") diff --git a/tests/test_sync_command.py b/tests/test_sync_command.py index b888125..eef42e7 100644 --- a/tests/test_sync_command.py +++ b/tests/test_sync_command.py @@ -173,3 +173,14 @@ def test_check_for_library_changes_without_history(tmp_path: Path) -> None: "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"] From 145731c84a87832572f36cf021429578287a91de Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:52:16 +0200 Subject: [PATCH 051/105] fix(sync): tell up-to-date libraries apart in any language sync looked for "Already up to date" in git's output, which git translates, so with a non-English locale every library counted as updated and was checked for new content. Compare HEAD before and after the pull instead. --- kicad_lib_manager/commands/sync/command.py | 3 ++- tests/test_sync_command.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/kicad_lib_manager/commands/sync/command.py b/kicad_lib_manager/commands/sync/command.py index dbc3edb..3f7e835 100644 --- a/kicad_lib_manager/commands/sync/command.py +++ b/kicad_lib_manager/commands/sync/command.py @@ -111,7 +111,8 @@ def sync( if result.returncode == 0: output = result.stdout.strip() or "Already up to date." - is_updated = "Already up to date" not in output + # git words its messages in the user's language; HEAD is exact + is_updated = _head_revision(lib_path) != revision_before_pull if verbose: get_console().print(f" [green]Success: {output}[/green]") diff --git a/tests/test_sync_command.py b/tests/test_sync_command.py index eef42e7..0e3223e 100644 --- a/tests/test_sync_command.py +++ b/tests/test_sync_command.py @@ -106,6 +106,22 @@ def test_sync_pulls_and_detects_new_libraries(upstream: Path, library: Path) -> assert "kilm sync --auto-setup" in result.output +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") + + result = CliRunner().invoke(app, ["sync"]) + + 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_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") From 3c4b2dc4131b14d3d92fb0f3a37fd43e5af09779 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:52:23 +0200 Subject: [PATCH 052/105] fix(sync): let a failed auto-setup report its error once typer.Exit is an Exception, so when setup exited during --auto-setup, sync's catch-all printed an extra "Error: 1", and an exit status of 0 from setup would have become 1. Re-raise typer.Exit unchanged. --- kicad_lib_manager/commands/sync/command.py | 3 +++ tests/test_sync_command.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/kicad_lib_manager/commands/sync/command.py b/kicad_lib_manager/commands/sync/command.py index 3f7e835..3b2a8eb 100644 --- a/kicad_lib_manager/commands/sync/command.py +++ b/kicad_lib_manager/commands/sync/command.py @@ -198,6 +198,9 @@ def sync( "[blue]Use 'kilm status' to check your current configuration.[/blue]" ) + except typer.Exit: + # setup reports its own errors + raise except Exception as e: get_console().print(f"[red]Error: {e}[/red]") raise typer.Exit(1) from e diff --git a/tests/test_sync_command.py b/tests/test_sync_command.py index 0e3223e..4bdfae0 100644 --- a/tests/test_sync_command.py +++ b/tests/test_sync_command.py @@ -146,6 +146,7 @@ def test_sync_auto_setup_runs_setup_with_its_defaults( 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 @@ -190,6 +191,7 @@ def test_check_for_library_changes_without_history(tmp_path: Path) -> None: "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" From 97452b2c6a5eb2dee4bf605925eb469a4635c725 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:53:32 +0200 Subject: [PATCH 053/105] refactor(config): type the configuration model and make its files injectable Config takes optional config_file and cache_dir so tests use temporary files instead of the real home directory. Loading no longer creates the configuration directory; saving does. Library entries are validated once on load. Unused methods (set, get_library_path, get_current_library_paths, get_update_preferences, set_update_preference) and every ConfigService method are removed; ConfigService remains only as the holder main.py constructs. user_paths.py names the per-user config file and cache dir. --- kicad_lib_manager/services/config_service.py | 593 ++++++------------- kicad_lib_manager/services/user_paths.py | 19 + tests/test_config_service.py | 199 +++++++ 3 files changed, 395 insertions(+), 416 deletions(-) create mode 100644 kicad_lib_manager/services/user_paths.py create mode 100644 tests/test_config_service.py diff --git a/kicad_lib_manager/services/config_service.py b/kicad_lib_manager/services/config_service.py index 0cb36bc..f9f7ca5 100644 --- a/kicad_lib_manager/services/config_service.py +++ b/kicad_lib_manager/services/config_service.py @@ -1,25 +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 @@ -27,477 +27,238 @@ 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 never triggers one. +""" + +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, *, config_file: Path | None = None, cache_dir: Path | None = None + ) -> None: + """Load the configuration. - def __init__(self): - """Initialize configuration with default values""" - self._config = DEFAULT_CONFIG.copy() + 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) + """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, 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_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 + """Return `config_file`; kept for callers that predate the attribute.""" + return self.config_file 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) + name: Library name. + path: Path to the library. + library_type: "github" for symbols and 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 + 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 - - Args: - name: Library name - library_type: Type of library ("github" or "cloud"). If None, remove all types. + """Unregister the libraries called `name`, of one type or of every type. Returns: - True if library was removed, False otherwise + Whether any library was removed. """ - 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 + 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 + 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] - 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. - - 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 the 3D model library to use: the default or else the first one.""" + return self._current_or_first("cloud") - 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 - if 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 + def _last_check_file(self) -> Path: + return self._cache_dir / LAST_UPDATE_CHECK_FILE - 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) - 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 - if value_lower in {"false", "0", "no"}: - return False - raise ValueError( - f"Invalid boolean value: '{value}'. " - f"Must be one of: true, false, 1, 0, yes, no (case-insensitive)" - ) +def _normalize_libraries(value: object) -> list[LibraryDict]: + """Return the valid library entries of a loaded `libraries` setting. - # Should never happen - raise ValueError( - f"Cannot coerce {type(value).__name__} to boolean. " - f"Expected bool or string, got {type(value).__name__}" + 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 [] + 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 _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"]), - ) - return None +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 + 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"]) + ) class ConfigService: - """Service wrapper for managing KiLM configuration.""" + """Holder of a `Config`, kept only because main.py still constructs it. - 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) - - 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 - return None + New code uses `Config` directly; remove this once main.py does. + """ - 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() + def __init__(self) -> None: + """Load the user's configuration into `_config`.""" + self._config = Config() 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/tests/test_config_service.py b/tests/test_config_service.py new file mode 100644 index 0000000..6e6cef8 --- /dev/null +++ b/tests/test_config_service.py @@ -0,0 +1,199 @@ +"""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, +) + +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_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_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() From 0a4633b1966e9be69054a324df4f40574d658faa Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:53:59 +0200 Subject: [PATCH 054/105] fix(config): clear the default library when it is removed config remove left current_library pointing at the removed path, and the note about the changed default could never print. Removing the last entry with the default path now clears the default and the command says so. --- kicad_lib_manager/commands/config/command.py | 2 +- kicad_lib_manager/commands/config/docs.mdx | 2 +- kicad_lib_manager/services/config_service.py | 4 +++ tests/test_config_commands.py | 10 ++++++++ tests/test_config_service.py | 27 ++++++++++++++++++++ 5 files changed, 43 insertions(+), 2 deletions(-) diff --git a/kicad_lib_manager/commands/config/command.py b/kicad_lib_manager/commands/config/command.py index de674e9..72dd4ef 100644 --- a/kicad_lib_manager/commands/config/command.py +++ b/kicad_lib_manager/commands/config/command.py @@ -532,7 +532,7 @@ def remove( current_library_new = config.get_current_library() if current_library and current_library != current_library_new: get_console().print( - "Note: Default library was changed as the previous default was removed." + "Note: The removed library was the default; no default is set now." ) else: get_console().print("No libraries were removed.") diff --git a/kicad_lib_manager/commands/config/docs.mdx b/kicad_lib_manager/commands/config/docs.mdx index 4694a77..c336e0d 100644 --- a/kicad_lib_manager/commands/config/docs.mdx +++ b/kicad_lib_manager/commands/config/docs.mdx @@ -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/services/config_service.py b/kicad_lib_manager/services/config_service.py index f9f7ca5..ee44b72 100644 --- a/kicad_lib_manager/services/config_service.py +++ b/kicad_lib_manager/services/config_service.py @@ -137,6 +137,8 @@ def add_library(self, name: str, path: str, library_type: str = "github") -> Non def remove_library(self, name: str, library_type: str | None = None) -> bool: """Unregister the libraries called `name`, of one type or of every type. + The default library is cleared once no remaining library has its path. + Returns: Whether any library was removed. """ @@ -150,6 +152,8 @@ def remove_library(self, name: str, library_type: str | None = None) -> bool: 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 diff --git a/tests/test_config_commands.py b/tests/test_config_commands.py index f919029..6bd600e 100644 --- a/tests/test_config_commands.py +++ b/tests/test_config_commands.py @@ -147,6 +147,16 @@ def test_config_remove(mock_config_class): assert remaining_libraries[0]["name"] == "test-cloud-lib" +def test_config_remove_default_library_reports_cleared_default(mock_config_class): + """Removing the default library says that no default is set any more.""" + runner = CliRunner() + result = runner.invoke(main, ["config", "remove", "test-github-lib", "--force"]) + + assert result.exit_code == 0 + assert "no default is set now" in result.output + assert mock_config_class.get_current_library() is None + + def test_config_remove_with_confirmation(mock_config_class): """Test 'kilm config remove' with confirmation prompt.""" runner = CliRunner() diff --git a/tests/test_config_service.py b/tests/test_config_service.py index 6e6cef8..8c551ac 100644 --- a/tests/test_config_service.py +++ b/tests/test_config_service.py @@ -113,6 +113,33 @@ def test_remove_library_by_type_or_every_type(config_file: Path) -> None: 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") From 887ea2750078d1665bc22b4a70dcada06904d2a7 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:53:59 +0200 Subject: [PATCH 055/105] refactor(sync): split syncing into typed steps Run git through one helper that resolves the executable, count outcomes with an enum, and keep the setup call outside the error boundary. --- kicad_lib_manager/commands/sync/command.py | 483 ++++++++++----------- 1 file changed, 220 insertions(+), 263 deletions(-) diff --git a/kicad_lib_manager/commands/sync/command.py b/kicad_lib_manager/commands/sync/command.py index 3b2a8eb..4aa0e92 100644 --- a/kicad_lib_manager/commands/sync/command.py +++ b/kicad_lib_manager/commands/sync/command.py @@ -1,19 +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 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 -from ...services.config_service import Config + +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.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, @@ -35,303 +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: - get_console().print( - "[yellow]No GitHub libraries configured. Use 'kilm init' to add a library.[/yellow]" + console.print( + "[yellow]No GitHub libraries configured. Use 'kilm init' to add a " + "library.[/yellow]" ) return - - get_console().print( + 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: - get_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(): - get_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(): - get_console().print( - f" [yellow]Skipping {lib_name}: Not a git repository: {lib_path}[/yellow]" - ) - skipped_count += 1 - continue - - # Prepare to run git pull - get_console().print(f" [cyan]Syncing {lib_name} at {lib_path}...[/cyan]") - - if dry_run: - get_console().print( - f" [blue]Dry run: would execute 'git pull' in {lib_path}[/blue]" - ) - updated_count += 1 - continue - - try: - # Remember HEAD so the change check covers every pulled commit - revision_before_pull = _head_revision(lib_path) - - # 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." - # git words its messages in the user's language; HEAD is exact - is_updated = _head_revision(lib_path) != revision_before_pull - - if verbose: - get_console().print(f" [green]Success: {output}[/green]") - # Also show the short status for consistency - if is_updated: - get_console().print(" [green]Updated[/green]") - else: - get_console().print(" [blue]Up to date[/blue]") - elif is_updated: - get_console().print(" [green]Updated[/green]") - else: - get_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, revision_before_pull or "HEAD~1" - ) - if changes_require_setup: - libraries_with_changes.append( - (lib_name, lib_path, changes_require_setup) - ) - if verbose: - get_console().print( - f" [yellow]Detected new library content: {', '.join(changes_require_setup)}[/yellow]" - ) - else: - get_console().print( - f" [red]Failed: {result.stderr.strip()}[/red]" - ) - failed_count += 1 - - except Exception as e: - get_console().print(f" [red]Error: {e!s}[/red]") - failed_count += 1 - - # Summary - get_console().print("\n[bold cyan]Sync Summary:[/bold cyan]") - get_console().print(f" [green]{updated_count}[/green] libraries synced") - get_console().print(f" [blue]{up_to_date_count}[/blue] libraries up to date") - get_console().print(f" [yellow]{skipped_count}[/yellow] libraries skipped") - get_console().print(f" [red]{failed_count}[/red] libraries failed") - - # If libraries with changes were detected, suggest running setup - if libraries_with_changes: - get_console().print("\n[yellow]New library content detected in:[/yellow]") - for lib_name, _lib_path, changes in libraries_with_changes: - get_console().print( - f" - [cyan]{lib_name}[/cyan]: {', '.join(changes)}" - ) - - if auto_setup: - get_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: - get_console().print( - "[red]Error: Could not import setup command. Please run 'kilm setup' manually.[/red]" - ) - else: - get_console().print( - "\n[yellow]You should run 'kilm setup' to configure the new libraries in KiCad.[/yellow]" - ) - get_console().print( - "[blue]Run 'kilm sync --auto-setup' next time to automatically run setup after sync.[/blue]" - ) - else: - get_console().print( - "\n[blue]No new libraries detected that would require running 'kilm setup'.[/blue]" - ) - get_console().print( - "[blue]Use 'kilm status' to check your current configuration.[/blue]" - ) - - except typer.Exit: - # setup reports its own errors - raise + outcomes, new_content = _sync_all(libraries, dry_run=dry_run, verbose=verbose) except Exception as e: - get_console().print(f"[red]Error: {e}[/red]") + console.print(f"[red]Error: {e}[/red]") raise typer.Exit(1) from e - -# TODO: Should be in services or utils -def _head_revision(lib_path: Path) -> str | None: - """Return the commit HEAD points at, or None when git cannot tell.""" - result = subprocess.run( - ["git", "rev-parse", "--verify", "--quiet", "HEAD"], - cwd=lib_path, - capture_output=True, - text=True, - check=False, - ) - return result.stdout.strip() or None if result.returncode == 0 else None + _print_summary(outcomes) + # Outside the try: setup reports its own errors through typer.Exit. + _report_new_content(new_content, auto_setup=auto_setup) def check_for_library_changes(lib_path: Path, since: str = "HEAD~1") -> list[str]: - """Check if git pull changes indicate new libraries that would require setup. - Uses git diff to analyze what files were added/changed since `since`. + """Return the kinds of library content added between ``since`` and HEAD. - Args: - lib_path: Path to the library directory - since: Revision to compare HEAD with, normally HEAD before the pull + The kinds are ``symbols``, ``footprints``, and ``templates``: content that + KiCad only sees after ``kilm setup``. 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. - Returns: - List of change types found ('symbols', 'footprints', 'templates') or empty list if none + Args: + lib_path: The library's git clone. + since: The revision to compare HEAD with, normally HEAD before a pull. """ - changes = [] - try: - # Get the diff between HEAD~1 and HEAD to see what changed in the pull - result = subprocess.run( - ["git", "diff", "--name-only", "--diff-filter=AMR", since, "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 - # Added, modified, and renamed paths (a rename under its new name) - for path in diff_output.splitlines(): - # 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 From f1fc050e2069104b94607698d8c4d19112e7f0fb Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:54:18 +0200 Subject: [PATCH 056/105] fix(config): explain an empty type filter in config list config list --type github (or cloud) without libraries of that type showed the generic "No libraries configured" panel even when libraries of the other type existed; the type-specific panels never printed. --- kicad_lib_manager/commands/config/command.py | 63 ++++++++++---------- tests/test_config_commands.py | 22 +++++++ 2 files changed, 53 insertions(+), 32 deletions(-) diff --git a/kicad_lib_manager/commands/config/command.py b/kicad_lib_manager/commands/config/command.py index 72dd4ef..aab1738 100644 --- a/kicad_lib_manager/commands/config/command.py +++ b/kicad_lib_manager/commands/config/command.py @@ -58,17 +58,38 @@ def list_config( if not libraries: get_console().print() - get_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", + if library_type == "github": + get_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": + get_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", + ) + ) + else: + get_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", + ) ) - ) return # Group libraries by type @@ -288,28 +309,6 @@ def list_config( ) ) - # Print helpful message if no libraries match the filter - if library_type == "github" and not types["github"]: - get_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"]: - get_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: get_console().print(f"[red]Error listing configurations: {e}[/red]") sys.exit(1) diff --git a/tests/test_config_commands.py b/tests/test_config_commands.py index 6bd600e..e397f84 100644 --- a/tests/test_config_commands.py +++ b/tests/test_config_commands.py @@ -83,6 +83,28 @@ def test_config_list_filtered(mock_config_class): assert "test-cloud-lib" not in result.output +@pytest.mark.parametrize( + ("library_type", "message", "hint"), + [ + ("github", "No GitHub libraries configured", "kilm init"), + ("cloud", "No cloud libraries configured", "kilm add-3d"), + ], +) +def test_config_list_type_without_libraries( + mock_config_class, library_type, message, hint +): + """A type filter without matches names that type and how to add one.""" + mock_config_class.remove_library( + "test-github-lib" if library_type == "github" else "test-cloud-lib" + ) + runner = CliRunner() + result = runner.invoke(main, ["config", "list", "--type", library_type]) + + assert result.exit_code == 0 + assert message in result.output + assert hint in result.output + + def test_config_set_default(mock_config_class): """Test the 'kilm config set-default' command.""" runner = CliRunner() From 45038737856ff9c71b330b526e3c4cdd5a7fe8c8 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:56:11 +0200 Subject: [PATCH 057/105] fix(setup): honor --max-backups when pinning libraries The pinned-library update always kept the default five backups of kicad_common.json, whatever --max-backups said. --- kicad_lib_manager/commands/setup/command.py | 1 + tests/test_setup_command.py | 27 ++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/kicad_lib_manager/commands/setup/command.py b/kicad_lib_manager/commands/setup/command.py index 5f1523a..f972a8a 100644 --- a/kicad_lib_manager/commands/setup/command.py +++ b/kicad_lib_manager/commands/setup/command.py @@ -564,6 +564,7 @@ def setup( symbol_libs=symbol_libs, footprint_libs=footprint_libs, dry_run=dry_run, + max_backups=max_backups, ) if pinned_changes_needed: diff --git a/tests/test_setup_command.py b/tests/test_setup_command.py index 5979600..003d10a 100644 --- a/tests/test_setup_command.py +++ b/tests/test_setup_command.py @@ -1,6 +1,7 @@ """Tests for ``kilm setup`` against a temporary KiCad configuration.""" import io +import json from collections.abc import Callable from pathlib import Path @@ -28,9 +29,7 @@ def kicad_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: home.mkdir() monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("USERPROFILE", str(home)) - monkeypatch.setattr( - setup_command, "find_environment_variables", lambda _name: None - ) + monkeypatch.setattr(setup_command, "find_environment_variables", lambda _name: None) config = tmp_path / "kicad" config.mkdir() (config / "sym-lib-table").write_text(EMPTY_SYM_TABLE, encoding="utf-8") @@ -109,3 +108,25 @@ def test_fix_invalid_uris_keeps_the_table_when_writing_fails( setup_command.fix_invalid_uris(kicad_config, backup_first=False) 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 From 6d9ba97119803682efe7e5d86f9615da41c41cb0 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:56:28 +0200 Subject: [PATCH 058/105] refactor(template): split template code into typed modules Replace utils/template.py with the utils/template package (constants, models, rendering, discovery, selection, kicad_files, authoring, hooks, project) and split the command module into create, make, and listing. plan_template_files is now the one place that decides what template make copies, previews, and scans. Metadata is parsed into TypedDicts, errors are caught by type, and the code passes the strict ruff and ty gates. --- .../commands/template/__init__.py | 24 +- .../commands/template/command.py | 1102 +------------- kicad_lib_manager/commands/template/create.py | 366 +++++ .../commands/template/listing.py | 161 ++ kicad_lib_manager/commands/template/make.py | 377 +++++ kicad_lib_manager/utils/template.py | 1341 ----------------- kicad_lib_manager/utils/template/__init__.py | 8 + kicad_lib_manager/utils/template/authoring.py | 175 +++ kicad_lib_manager/utils/template/constants.py | 119 ++ kicad_lib_manager/utils/template/discovery.py | 103 ++ kicad_lib_manager/utils/template/hooks.py | 109 ++ .../utils/template/kicad_files.py | 159 ++ kicad_lib_manager/utils/template/models.py | 149 ++ kicad_lib_manager/utils/template/project.py | 148 ++ kicad_lib_manager/utils/template/rendering.py | 190 +++ kicad_lib_manager/utils/template/selection.py | 252 ++++ tests/test_template.py | 488 +++--- tests/test_template_commands.py | 8 +- 18 files changed, 2531 insertions(+), 2748 deletions(-) create mode 100644 kicad_lib_manager/commands/template/create.py create mode 100644 kicad_lib_manager/commands/template/listing.py create mode 100644 kicad_lib_manager/commands/template/make.py delete mode 100644 kicad_lib_manager/utils/template.py create mode 100644 kicad_lib_manager/utils/template/__init__.py create mode 100644 kicad_lib_manager/utils/template/authoring.py create mode 100644 kicad_lib_manager/utils/template/constants.py create mode 100644 kicad_lib_manager/utils/template/discovery.py create mode 100644 kicad_lib_manager/utils/template/hooks.py create mode 100644 kicad_lib_manager/utils/template/kicad_files.py create mode 100644 kicad_lib_manager/utils/template/models.py create mode 100644 kicad_lib_manager/utils/template/project.py create mode 100644 kicad_lib_manager/utils/template/rendering.py create mode 100644 kicad_lib_manager/utils/template/selection.py diff --git a/kicad_lib_manager/commands/template/__init__.py b/kicad_lib_manager/commands/template/__init__.py index 686fc11..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 - -# 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) +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 __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 9cd5a4a..7d1ca41 100644 --- a/kicad_lib_manager/commands/template/command.py +++ b/kicad_lib_manager/commands/template/command.py @@ -1,47 +1,10 @@ -"""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.""" -# PEP 810: on Python 3.15+ these imports load on first use. -__lazy_modules__ = {"jinja2", "pathspec", "questionary", "yaml"} - -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.panel import Panel -from rich.table import Table -from kicad_lib_manager.utils.console import get_console - -from ...services.config_service import Config -from ...utils.template import ( - FILENAME_VAR_PATTERN, - HOOKS_DIR, - POST_CREATE_HOOK, - TEMPLATE_CONTENT_DIR, - TEMPLATE_METADATA, - EXCLUDED_DIRECTORIES, - KICAD_PRL_EXT, - 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, -) +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 def main_callback() -> None: @@ -52,1047 +15,16 @@ def main_callback() -> None: """ -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, library) - - if not all_templates: - where = f"library '{library}'" if library else "any configured libraries" - get_console().print(f"No templates found in {where}.") - get_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: - get_console().print(f"Template '{template}' not found.") - get_console().print("Available templates:") - for t_name, t_data in all_templates.items(): - source = t_data.get("source_library", "unknown") - description = t_data.get("description", "") - get_console().print(f" {t_name} ({source}): {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 - get_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: - get_console().print("[red]Error: No template selected.[/red]") - return - - # Get template directory - template_path_str = selected_template.get("path") - if not template_path_str: - get_console(stderr=True).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 - get_console(stderr=True).print( - f"Warning: Template metadata file is empty: {metadata_file}" - ) - metadata = {} # Use empty dict to avoid downstream errors - except Exception as e: - get_console(stderr=True).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 - get_console().print() - get_console().print(f"Using Template: {metadata.get('name', 'Unknown')}") - get_console().print(f"Description: {metadata.get('description', 'N/A')}") - if metadata.get("use_case"): - get_console().print(f"Use case: {metadata.get('use_case')}") - - get_console().print() - get_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): - get_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) - or FILENAME_VAR_PATTERN.search(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 - get_console(stderr=True).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: - get_console(stderr=True).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 - get_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: - get_console(stderr=True).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 - ): - get_console().print( - f"Updating default for '{var_name}': '{shown_default}' -> '{correct_default}'" - ) - variables[var_name] = correct_default - # --- End Post-processing --- - - get_console().print() - get_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: - get_console(stderr=True).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 - get_console().print( - f"Using project_name to determine directory: {final_project_dir}" - ) - - # Final check if we have a directory - if not final_project_dir: - get_console(stderr=True).print( - "Error: Cannot determine project directory. Ensure 'project_name' or 'directory_name' variable is properly handled." - ) - return - # --- End Directory Determination --- - - get_console().print() - get_console().print(f"Project will be created in: {final_project_dir}") - - # --- Execution --- - if dry_run: - get_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?" - ): - get_console().print("Aborted.") - return - get_console().print(f"Removing existing directory: {final_project_dir}") - try: - shutil.rmtree(final_project_dir) - except Exception as e: - get_console(stderr=True).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: - get_console(stderr=True).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: - get_console().print() - get_console(stderr=True).print( - "WARNING: Windows Compatibility Notice:" - ) - get_console(stderr=True).print( - "This template uses the old {{variable}} syntax in filenames, which may not work on Windows." - ) - get_console(stderr=True).print( - "Consider updating the template to use the new Windows-compatible %{variable} syntax." - ) - get_console(stderr=True).print("Files with old syntax:") - for file in old_syntax_files[:3]: # Show first 3 files - get_console(stderr=True).print(f" - {file}") - if len(old_syntax_files) > 3: - get_console(stderr=True).print( - f" ... and {len(old_syntax_files) - 3} more" - ) - get_console().print() - get_console(stderr=True).print("New syntax examples:") - get_console(stderr=True).print(" - %{project_name}.kicad_pro") - get_console(stderr=True).print( - " - %{project_name.lower}.kicad_sch" - ) - get_console(stderr=True).print( - " - %{project_name.replace(' ', '-')}.kicad_pcb" - ) - get_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: - get_console(stderr=True).print(f"Error during project creation: {e}") - traceback.print_exc() # Print traceback for debugging - success = False - - if success: - if not dry_run: - get_console().print() - get_console().print( - f"Project '{variables.get('project_name', name)}' created successfully in '{final_project_dir}'" - ) - else: - get_console().print() - get_console(stderr=True).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: - get_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) - - # 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 - get_console().print("\nChoose a library to store the template:") - for i, lib_name in enumerate(library_names): - get_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 - get_console().print( - f"Please enter a number between 1 and {len(library_names)}" - ) - except ValueError: - get_console().print("Please enter a valid number") - - output_directory = Path(library_path) / TEMPLATES_DIR / name - get_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: - get_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: - get_console().print(f"Template '{name}' already exists at {output_directory}") - get_console().print("Use --force to overwrite.") - return - - # Get gitignore spec - gitignore_spec = get_gitignore_spec(source_directory) - - # Show what we're going to do - get_console().print(f"Creating template '{name}' from {source_directory}") - get_console().print(f"Output directory: {output_directory}") - - if description: - get_console().print(f"Description: {description}") - if use_case: - get_console().print(f"Use case: {use_case}") - if exclude: - get_console().print("Additional exclusions:") - for pattern in exclude: - get_console().print(f" {pattern}") - if extends: - get_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: - get_console().print("\nTemplate variables:") - for key, value in variable_dict.items(): - get_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: - get_console().print("\nFound potential template variables:") - for var_name, values in potential_vars.items(): - value_str = ", ".join(values) - get_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 "" - var_description = typer.prompt( - " Description", default=f"Value for {var_name}" - ) - - detected_variables[var_name] = { - "description": var_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 ( - d in EXCLUDED_DIRECTORIES - or output_directory.as_posix() - in str(source_directory / rel_path) - or (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 - if file.lower().endswith(KICAD_PRL_EXT): - excluded_files.append(rel_path) - continue - - included_files.append(rel_path) - - # Sort and display - included_files.sort() - excluded_files.sort() - - get_console().print("\nFiles that will be included in the template:") - for file in included_files: - get_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: - get_console().print( - "\nMarkdown files that will be converted to Jinja templates:" - ) - for file in md_files: - get_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: - get_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 - - get_console().print(f" * {file} → {templated_name}") - - get_console().print("\nFiles that will be excluded from the template:") - for file in excluded_files: - get_console().print(f" - {file}") - - # If this is a dry run, stop here - if dry_run: - get_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, - ) - - get_console().print( - f"\nTemplate '{name}' created successfully at {output_directory}" - ) - - # Add hints for next steps - get_console().print("\nNext steps:") - get_console().print( - f"1. Edit {output_directory / TEMPLATE_METADATA if output_directory else 'N/A'} to customize template metadata" - ) - get_console().print( - f"2. Customize template files in {output_directory / TEMPLATE_CONTENT_DIR if output_directory else 'N/A'}" - ) - get_console().print( - f"3. Edit post-creation hook in {output_directory / HOOKS_DIR / POST_CREATE_HOOK if output_directory else 'N/A'} if needed" - ) - get_console().print( - f"4. Use your template with: kilm template create MyProject --template {name}" - ) - - # Add information about filename templating syntax - get_console().print("\nFilename Templating:") - get_console().print( - "For Windows compatibility, use %{variable} syntax in filenames:" - ) - get_console().print(" - %{project_name}.kicad_pro") - get_console().print(" - %{project_name.lower}.kicad_sch") - get_console().print(" - %{project_name.replace(' ', '-')}.kicad_pcb") - get_console().print(" - %{project_name.upper.replace(' ', '_')}.md") - get_console().print( - "(Old {{variable}} syntax still works but may cause issues on Windows)" - ) - - except Exception as e: - get_console(stderr=True).print(f"Error creating template: {e!s}") - 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() - all_templates = find_all_templates(config, library) - - if not all_templates and library: - get_console().print() - get_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 not all_templates: - get_console().print() - get_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 - - # If JSON output is requested - if json_output: - # Plain echo: a Rich console would wrap long values and break the JSON. - typer.echo(json.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 - get_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 or "[dim]None[/dim]") - - table.add_row(*row_data) - - get_console().print(table) - get_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 " - - get_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}" - - get_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", + callback=main_callback, + 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..36ba326 --- /dev/null +++ b/kicad_lib_manager/commands/template/create.py @@ -0,0 +1,366 @@ +"""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.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: + r"""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 + """ + 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 _parse_assignments(assignments: list[str]) -> dict[str, str]: + """Parse ``key=value`` options; entries without ``=`` are ignored.""" + parsed: dict[str, str] = {} + for assignment in assignments: + key, separator, value = assignment.partition("=") + if separator: + parsed[key.strip()] = value.strip() + return parsed + + +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: + """Replace an existing project directory, after confirmation, and 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/listing.py b/kicad_lib_manager/commands/template/listing.py new file mode 100644 index 0000000..6b540db --- /dev/null +++ b/kicad_lib_manager/commands/template/listing.py @@ -0,0 +1,161 @@ +"""The ``template list`` command.""" + +import json +from typing import Annotated + +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: + r"""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 + """ + 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. + typer.echo(json.dumps(templates, indent=2)) + 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 list-libraries[/blue]\n" + f"• Create template in '{library}': [blue]kilm template make" + f" --library {library}[/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 list-libraries[/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 diff --git a/kicad_lib_manager/commands/template/make.py b/kicad_lib_manager/commands/template/make.py new file mode 100644 index 0000000..005ad17 --- /dev/null +++ b/kicad_lib_manager/commands/template/make.py @@ -0,0 +1,377 @@ +"""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.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 +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: + r"""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 + """ + 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]: + """Parse ``--variable name=value`` options and show them.""" + variables: dict[str, TemplateVariable] = {} + for assignment in assignments: + key, separator, value = assignment.partition("=") + if separator: + key = key.strip() + variables[key] = TemplateVariable( + description=f"Value for {key}", default=value.strip() + ) + 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=f"Value for {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=f"Value for {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/utils/template.py b/kicad_lib_manager/utils/template.py deleted file mode 100644 index 9a12541..0000000 --- a/kicad_lib_manager/utils/template.py +++ /dev/null @@ -1,1341 +0,0 @@ -"""Template utility functions for KiCad Library Manager. -Provides functions for working with project templates. -""" - -# PEP 810: on Python 3.15+ these imports load on first use. -__lazy_modules__ = {"jinja2", "pathspec", "yaml"} - -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*["\']([^"\']+)["\']', -] - -# 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, - } -) - -# 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, dirs, files in os.walk(directory): - dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRECTORIES] - 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 - ) - - common_excludes = EXCLUDED_DIRECTORIES - - # 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: Path | None = None - main_schematic_file: Path | None = None - main_pcb_file: Path | 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 = 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 = 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 = 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 - 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, library: str | None = None -) -> dict[str, TemplateMetadata]: - """Find all templates in all configured libraries. - - Args: - config: The configuration object - library: Only search the library with this name (case-insensitive) - - Returns: - Dictionary mapping template names to template metadata - """ - all_templates: dict[str, TemplateMetadata] = {} - all_libraries = config.get_libraries() # type: ignore[attr-defined] - - if library is not None: - all_libraries = [ - lib for lib in all_libraries if lib.get("name", "").lower() == library.lower() - ] - - 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"): - # No HTML escaping: templates render KiCad and text files. - env = jinja2.Environment( - loader=jinja2.FileSystemLoader(source_file.parent), - autoescape=False, - 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) - target_path_str = target_path_str.removesuffix( - ".jinja2" - ) # 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 metadata.get("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 - target_filename = target_filename.removesuffix(".jinja2") - - # 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..83e1486 --- /dev/null +++ b/kicad_lib_manager/utils/template/authoring.py @@ -0,0 +1,175 @@ +"""Making a template from an existing project, for ``template make``.""" + +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=_or_default(description, f"KiCad project template for {name}"), + use_case=_or_default(use_case, ""), + 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}", + ), + } + + +def _or_default(value: str | None, default: str) -> str: + """Return ``value`` unless it is None or empty.""" + return default if value is None or not value else value diff --git a/kicad_lib_manager/utils/template/constants.py b/kicad_lib_manager/utils/template/constants.py new file mode 100644 index 0000000..113250a --- /dev/null +++ b/kicad_lib_manager/utils/template/constants.py @@ -0,0 +1,119 @@ +"""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 + +# Patterns added to a project's .gitignore when choosing files for a template. +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..b0c40e8 --- /dev/null +++ b/kicad_lib_manager/utils/template/discovery.py @@ -0,0 +1,103 @@ +"""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, + 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( + DiscoveredTemplate( + **metadata, + path=str(template_dir), + source_library=directory.name, + library_path=str(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..164487b --- /dev/null +++ b/kicad_lib_manager/utils/template/hooks.py @@ -0,0 +1,109 @@ +"""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. +HOOK_SCRIPT = '''""" +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']}") +''' + + +class HookContext(TypedDict): + """The argument passed to a template's ``post_create`` function.""" + + 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..df7d675 --- /dev/null +++ b/kicad_lib_manager/utils/template/kicad_files.py @@ -0,0 +1,159 @@ +"""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_NAME_VARIABLES, +) +from kicad_lib_manager.utils.template.models import TemplateVariable + +PROJECT_FILENAME_TEMPLATE = "{{ project_filename }}" +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..9f9bd65 --- /dev/null +++ b/kicad_lib_manager/utils/template/models.py @@ -0,0 +1,149 @@ +"""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 Mapping +from pathlib import Path +from typing import TypeAlias, TypedDict + +import yaml + +from kicad_lib_manager.utils.constants import TEMPLATE_METADATA + +# 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 + + +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, a + variable without a description or default gets the ones ``template create`` + shows, and malformed values are dropped. Unknown keys are dropped. Returns + None unless ``raw`` is a non-empty mapping. + """ + fields = _string_keyed(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 := _string_keyed(fields.get("variables"))) is not None: + metadata["variables"] = _variables(variables) + if (dependencies := _string_keyed(fields.get("dependencies"))) is not None: + metadata["dependencies"] = _dependencies(dependencies) + return metadata + + +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 _string_keyed(value: object) -> dict[str, object] | None: + """Return a YAML mapping with its keys as text, or None for other values.""" + if not isinstance(value, Mapping): + return None + return {str(key): item for key, item in value.items()} + + +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 _variables(raw: dict[str, object]) -> dict[str, TemplateVariable]: + variables: dict[str, TemplateVariable] = {} + for name, value in raw.items(): + fields = _string_keyed(value) or {} + description = _text(fields.get("description")) + default = _text(fields.get("default")) + variables[name] = TemplateVariable( + description=f"Value for {name}" if description is None else description, + default="" if default is None else default, + ) + return variables + + +def _dependencies(raw: dict[str, object]) -> TemplateDependencies: + recommended = raw.get("recommended") + names = recommended if isinstance(recommended, list) else [] + return TemplateDependencies( + recommended=[text for item in names if (text := _text(item)) is not None] + ) diff --git a/kicad_lib_manager/utils/template/project.py b/kicad_lib_manager/utils/template/project.py new file mode 100644 index 0000000..8ec3d46 --- /dev/null +++ b/kicad_lib_manager/utils/template/project.py @@ -0,0 +1,148 @@ +"""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.""" + if not hook_script.exists(): + return + 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..d1e09bf --- /dev/null +++ b/kicad_lib_manager/utils/template/rendering.py @@ -0,0 +1,190 @@ +"""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 the file was not rendered as intended or could not be + written at all, True otherwise. + """ + 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..6c2b80e --- /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, the copy, and the variable scan agree. +""" + +# 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 + +import click +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; defaults to 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. + """ + compiled = [ + re.compile(pattern) for pattern in patterns or DEFAULT_VARIABLE_PATTERNS + ] + 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 + gitignore: pathspec.PathSpec | None + additional: pathspec.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 _is_within( + self.source / relative, self.template_root + ): + 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 _is_within(path: Path, directory: Path) -> bool: + """Whether ``path`` is ``directory`` or inside it, after resolving links.""" + try: + path.resolve().relative_to(directory.resolve()) + except ValueError: + return False + return True + + +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/tests/test_template.py b/tests/test_template.py index 82e9dde..c71cee6 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -1,336 +1,225 @@ -"""Tests for template utilities and commands.""" +"""Tests for the template utilities.""" import json -import shutil -import tempfile +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, - find_potential_variables, +) +from kicad_lib_manager.utils.template.models import ( + DiscoveredTemplate, + TemplateMetadata, + 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) +def test_render_template_string() -> None: + """Jinja2 placeholders are filled from the variables.""" + assert render_template_string("Hello, {{ name }}!", {"name": "World"}) == ( + "Hello, World!" + ) + + +@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", + ] - # 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) +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" - # 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"] + assert render_template_file(source, target, {"name": "World"}) + assert target.read_text(encoding="utf-8") == "Hello, World!" - 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) - template_file = template_dir / "test.txt.jinja2" - with Path(template_file).open("w") as f: - f.write("Hello, {{ name }}!") +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 the template file - target_dir = Path(self.temp_dir) / "target" - target_dir.mkdir(exist_ok=True) + render_template_file(source, target, {"project_name": "R&D <1>"}) - target_file = target_dir / "test.txt" + assert target.read_text(encoding="utf-8") == "R&D <1>" - variables = {"name": "World"} - success = render_template_file( - source_file=template_file, target_file=target_file, variables=variables - ) +def _discovered(template_dir: Path, metadata: TemplateMetadata) -> DiscoveredTemplate: + return DiscoveredTemplate( + **metadata, + path=str(template_dir), + source_library="library", + library_path=str(template_dir.parent), + ) - 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 +@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" - # Check that files were NOT created - assert not project_dir.exists() + 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_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() 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") @@ -339,15 +228,22 @@ def test_main_kicad_files_are_named_after_the_new_project(tmp_path: Path) -> Non """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"}}') - (source / "Board.kicad_sch").write_text('(kicad_sch (project "Board"))') - (source / "Board.kicad_pcb").write_text('(kicad_pcb (sheetfile "Board.kicad_sch"))') + (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" - create_template_structure(source, template_dir, create_template_metadata("tpl")) + metadata = create_template_metadata("tpl") + create_template_structure(source, template_dir, metadata) project = tmp_path / "project" create_project_from_template( - template_dir, + _discovered(template_dir, metadata), project, {"project_name": "Widget", "project_filename": "Widget"}, skip_hooks=True, @@ -360,26 +256,18 @@ def test_main_kicad_files_are_named_after_the_new_project(tmp_path: Path) -> Non ] project_file = json.loads((project / "Widget.kicad_pro").read_text()) assert project_file["meta"]["filename"] == "Widget.kicad_pro" - assert '(project "Widget")' in (project / "Widget.kicad_sch").read_text() - assert '(sheetfile "Widget.kicad_sch")' in (project / "Widget.kicad_pcb").read_text() - -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 }}") - target = tmp_path / "title.txt" - - render_template_file(source, target, {"project_name": "R&D <1>"}) + 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 - assert target.read_text() == "R&D <1>" 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"') - (tmp_path / "notes.txt").write_text('author = "Jane"') + (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"]} - -if __name__ == "__main__": - pytest.main() diff --git a/tests/test_template_commands.py b/tests/test_template_commands.py index 1af3954..da93988 100644 --- a/tests/test_template_commands.py +++ b/tests/test_template_commands.py @@ -101,9 +101,11 @@ def test_create_uses_template_from_requested_library( def test_list_filters_library_before_resolving_name_clashes(home: Path) -> None: - """--library lists that library's template even when an earlier one shares its name.""" + """--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) + add_template( + add_library(home, library_name), "board", {}, description=library_name + ) templates = json.loads(invoke("list", "--library", "second", "--json")) @@ -119,7 +121,7 @@ def test_create_renders_percent_syntax_defaults( class AcceptDefault: def __init__(self, message: str, default: str) -> None: self.answer = default - offered[message.split()[0]] = default + offered[message.split(maxsplit=1)[0]] = default def ask(self) -> str: return self.answer From bcb92a0b4f529249552df415de786e1969c72d41 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:56:55 +0200 Subject: [PATCH 059/105] fix(template): write a starting post_create hook that runs The hook written by template make came from a non-raw string, so the newline escapes in its commented example split a string literal across lines, and it called click.echo without importing click. Every project created from such a template reported a SyntaxError traceback. The hook is now a raw string and prints with print(). --- kicad_lib_manager/utils/template/hooks.py | 25 +++++++++++++---------- tests/test_template.py | 18 ++++++++++++++++ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/kicad_lib_manager/utils/template/hooks.py b/kicad_lib_manager/utils/template/hooks.py index 164487b..f3a4f83 100644 --- a/kicad_lib_manager/utils/template/hooks.py +++ b/kicad_lib_manager/utils/template/hooks.py @@ -12,12 +12,14 @@ HOOK_MODULE_NAME = "hook" HOOK_FUNCTION = "post_create" -# The starting point written into every new template's hooks directory. -HOOK_SCRIPT = '''""" +# 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. @@ -33,21 +35,22 @@ def post_create(context): """ # Uncomment and modify as needed # import subprocess - # import os + # from pathlib import Path # Example: Initialize git repository - # subprocess.run(["git", "init"], cwd=context["project_dir"]) + # subprocess.run(["git", "init"], cwd=context["project_dir"], check=True) # 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") + # 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 - click.echo(f"Project {context['variables']['project_name']} created successfully!") - click.echo(f"Location: {context['project_dir']}") + print(f"Project {context['variables']['project_name']} created successfully!") + print(f"Location: {context['project_dir']}") ''' diff --git a/tests/test_template.py b/tests/test_template.py index c71cee6..a414c67 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -20,6 +20,10 @@ ConfiguredLibrary, find_all_templates, ) +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, @@ -193,6 +197,20 @@ def test_create_project_dry_run_writes_nothing( 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.""" From c963102c6e95643d38d91aa053968ee37e04c0dd Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:57:05 +0200 Subject: [PATCH 060/105] fix(template): drop emojis and a nonexistent make option from list output --- kicad_lib_manager/commands/template/listing.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/kicad_lib_manager/commands/template/listing.py b/kicad_lib_manager/commands/template/listing.py index 6b540db..311f4b2 100644 --- a/kicad_lib_manager/commands/template/listing.py +++ b/kicad_lib_manager/commands/template/listing.py @@ -76,7 +76,7 @@ def list_templates( " \n" "[cyan]Verbose:[/cyan] kilm template list --verbose\n" "[cyan]Filter:[/cyan] kilm template list --library ", - title="[bold blue]💡 Quick Tips[/bold blue]", + title="[bold blue]Quick Tips[/bold blue]", border_style="blue", ) ) @@ -84,7 +84,7 @@ def list_templates( Panel( f"[green]Libraries:[/green] {len(by_library)}\n" f"[green]Templates:[/green] {len(templates)}", - title="[bold cyan]📊 Summary[/bold cyan]", + title="[bold cyan]Summary[/bold cyan]", border_style="cyan", width=30, ) @@ -98,21 +98,20 @@ def _print_no_templates(library: str | None) -> None: panel = Panel( f"[yellow]No templates found in library '[cyan]{library}[/cyan]'." "[/yellow]\n\n" - "[cyan]💡 Try:[/cyan]\n" + "[cyan]Try:[/cyan]\n" "• List all libraries: [blue]kilm list-libraries[/blue]\n" - f"• Create template in '{library}': [blue]kilm template make" - f" --library {library}[/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]", + 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" + "[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]", + title="[bold yellow]No Templates[/bold yellow]", border_style="yellow", ) get_console().print(panel) From abb94dc80db8b5e1fe336369670b35bd1c914bb2 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:57:06 +0200 Subject: [PATCH 061/105] refactor(config): bring the config commands up to the strict lint rules One renderer per library type replaces the duplicated GitHub and cloud code, options are keyword-only, and only OSError from saving is caught instead of every exception. Library names, paths, and metadata are escaped so brackets in them no longer read as Rich markup. Output uses plain words instead of check and cross glyphs; the interactive selection re-prompts through click.IntRange. Tests run against a temporary configuration through config_app, so they neither read the user's configuration nor run the update check. The docs name the real cloud metadata file (.kilm_metadata). --- kicad_lib_manager/commands/config/__init__.py | 6 +- kicad_lib_manager/commands/config/command.py | 799 +++++++----------- kicad_lib_manager/commands/config/docs.mdx | 2 +- tests/test_config_commands.py | 301 ++++--- 4 files changed, 510 insertions(+), 598 deletions(-) diff --git a/kicad_lib_manager/commands/config/__init__.py b/kicad_lib_manager/commands/config/__init__.py index 228701e..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,7 +11,6 @@ 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 diff --git a/kicad_lib_manager/commands/config/command.py b/kicad_lib_manager/commands/config/command.py index aab1738..c899115 100644 --- a/kicad_lib_manager/commands/config/command.py +++ b/kicad_lib_manager/commands/config/command.py @@ -1,32 +1,72 @@ -"""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 kicad_lib_manager.services.config_service import Config, LibraryDict from kicad_lib_manager.utils.console import get_console - -from ...services.config_service import Config -from ...utils.metadata import ( - CLOUD_METADATA_FILE, - GITHUB_METADATA_FILE, - read_cloud_metadata, - read_github_metadata, +from kicad_lib_manager.utils.constants import CLOUD_METADATA_FILE, GITHUB_METADATA_FILE +from kicad_lib_manager.utils.metadata import read_cloud_metadata, read_github_metadata + +MODEL_EXTENSIONS: Final = (".step", ".stp", ".wrl", ".wings") +"""File extensions counted as 3D models when a library does not state its count.""" + + +@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[ @@ -34,408 +74,252 @@ def list_config( typer.Option("-v", "--verbose", help="Show more information about libraries"), ] = False, ) -> None: - """List all configured libraries in kilm. - - This shows all libraries stored in the kilm configuration file. - There are two types of libraries: + """List the configured libraries of one type or of all types. - 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: - get_console().print() - if library_type == "github": - get_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": - get_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", - ) - ) - else: - get_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", - ) - ) - return - - # 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) - - get_console().print() - - # Display GitHub Libraries - if library_type in ["all", "github"] and types["github"]: - get_console().print( - "\n[bold cyan]GitHub Libraries[/bold cyan] [dim](symbols, footprints, templates)[/dim]" - ) - get_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 metadata.get("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}]" - - get_console().print( - Panel( - content, - title=title, - border_style="cyan" if path == current_library else "dim", - ) - ) - get_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) - - get_console().print(table) - get_console().print() - - # Display Cloud Libraries - if library_type in ["all", "cloud"] and types["cloud"]: - get_console().print( - "\n[bold cyan]Cloud Libraries[/bold cyan] [dim](3D models)[/dim]" + 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", ) - get_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 metadata.get("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}]" - - get_console().print( - Panel( - content, - title=title, - border_style="cyan" if path == current_library else "dim", - ) - ) - get_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) - - get_console().print(table) - get_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 + + 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, + ) + ) + - get_console().print( +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() + 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", ) ) - - except Exception as e: - get_console().print(f"[red]Error listing configurations: {e}[/red]") - sys.exit(1) + console.print() + + +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 isinstance(capabilities, dict): + features = [ + label + for key, label in ( + ("symbols", "Symbols"), + ("footprints", "Footprints"), + ("templates", "Templates"), + ) + if capabilities.get(key) + ] + 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: object, version: object, env_var: object) -> 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(str(description))}") + if version is not None: + lines.append(f"[yellow]Version:[/yellow] {escape(str(version))}") + if env_var: + lines.append(f"[magenta]Environment Variable:[/magenta] {escape(str(env_var))}") + return lines + + +def _counted_models(path: Path) -> str: + """Return the model count line for a library by counting its model files.""" + count = sum( + 1 + for file in path.rglob("*") + if file.suffix in MODEL_EXTENSIONS and file.is_file() + ) + 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 + """Make a library of the given type the default; prompt when no name is given. - \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 - - \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: - get_console().print(f"No {library_type} libraries configured.") - if library_type == "github": - get_console().print("Use 'kilm init' to initialize a GitHub library.") - else: - get_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: - get_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)" - - get_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 - get_console().print( - f"Please enter a number between 1 and {len(libraries)}" - ) - except ValueError: - get_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: - get_console().print( - f"No {library_type} library named '{library_name}' found." - ) - get_console().print( - "Use 'kilm config list' to see available libraries." - ) - sys.exit(1) - - # Set as current library - if library_path is None: - get_console().print( - f"[red]Error: Could not find path for library '{library_name}'[/red]" + 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"No {library_type} library named '{library_name}' found.", markup=False ) - sys.exit(1) - config.set_current_library(library_path) - get_console().print(f"Set {library_type} library '{library_name}' as default.") - get_console().print(f"Path: {library_path}") + console.print(LIST_HINT) + raise typer.Exit(1) - except Exception as e: - get_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( @@ -447,95 +331,62 @@ def remove( bool, typer.Option("--force", help="Force removal without confirmation") ] = False, ) -> None: - """Remove a library from the configuration. + """Unregister a library of one type or of every type, after confirmation. - Removes the specified library from the KiCad Library Manager configuration. - This does not delete any files, it only removes the library from the configuration. + 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 - Examples: - \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": - get_console().print(f"No library named '{library_name}' found.") - else: - get_console().print( - f"No {library_type} library named '{library_name}' found." - ) - get_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") - get_console().print( - f"Will remove {lib_type} library '{library_name}' from configuration." - ) - get_console().print(f"Path: {lib_path}") - - if lib_path == current_library: - get_console().print("Warning: This is the current default library.") - - if not typer.confirm("Continue?"): - get_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: - get_console().print( - f"Removed library '{library_name}' from configuration." - ) - else: - get_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: - get_console().print( - "Note: The removed library was the default; no default is set now." - ) - else: - get_console().print("No libraries were removed.") - - except Exception as e: - get_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 c336e0d..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`: diff --git a/tests/test_config_commands.py b/tests/test_config_commands.py index e397f84..9dcf640 100644 --- a/tests/test_config_commands.py +++ b/tests/test_config_commands.py @@ -1,199 +1,260 @@ -"""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 @pytest.mark.parametrize( - ("library_type", "message", "hint"), + ("library_type", "removed", "message", "hint"), [ - ("github", "No GitHub libraries configured", "kilm init"), - ("cloud", "No cloud libraries configured", "kilm add-3d"), + ("github", "test-github-lib", "No GitHub libraries configured", "kilm init"), + ("cloud", "test-cloud-lib", "No cloud libraries configured", "kilm add-3d"), ], ) -def test_config_list_type_without_libraries( - mock_config_class, library_type, message, hint -): +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.""" - mock_config_class.remove_library( - "test-github-lib" if library_type == "github" else "test-cloud-lib" - ) - runner = CliRunner() - result = runner.invoke(main, ["config", "list", "--type", library_type]) + stored(config_file).remove_library(removed) + + result = invoke("list", "--type", library_type) assert result.exit_code == 0 assert message in result.output assert hint 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"] - ) +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 "Set cloud library 'test-cloud-lib' as default" in result.output - assert mock_config_class.get_current_library() == TEST_LIBRARIES[1]["path"] + assert "No libraries configured" 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_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] + + result = invoke("set-default", *arguments, user_input="5\n1\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 "5 is not in the range 1<=x<=1" in result.output + assert stored(config_file).get_current_library() == library["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_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"]) +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") - assert result.exit_code == 0 - assert "Removed library 'test-github-lib'" in result.output + result = invoke("set-default", "--type", "cloud") - # 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" + assert result.exit_code == 1 + assert "No cloud libraries configured." in result.output + assert "kilm add-3d" in result.output -def test_config_remove_default_library_reports_cleared_default(mock_config_class): +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-cloud-lib'" in result.output + assert "Note:" not in result.output + assert stored(config_file).get_libraries() == [libraries[0]] + + +def test_remove_default_library_reports_cleared_default(config_file: Path) -> None: """Removing the default library says that no default is set any more.""" - runner = CliRunner() - result = runner.invoke(main, ["config", "remove", "test-github-lib", "--force"]) + result = invoke("remove", "test-github-lib", "--force") assert result.exit_code == 0 assert "no default is set now" in result.output - assert mock_config_class.get_current_library() is None + assert stored(config_file).get_current_library() is None -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") +def test_remove_asks_for_confirmation(config_file: Path) -> 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 len(stored(config_file).get_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) -> 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 [library["type"] for library in stored(config_file).get_libraries()] == [ + "github", + "cloud", + ] + + +def test_remove_every_type_reports_count(config_file: Path) -> 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 -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 From f30f6c01db70d8cf5dfad089add86162e6a81633 Mon Sep 17 00:00:00 2001 From: barisgit Date: Mon, 12 Oct 2026 23:59:19 +0200 Subject: [PATCH 062/105] fix(setup): escape quotes in library table entries A library description (from library_descriptions.yaml) or name with a double quote, backslash, or newline was written verbatim, which ends the string early and corrupts sym-lib-table or fp-lib-table. Entry fields are now quoted the way KiCad's OUTPUTFORMATTER::Quotes writes them. --- kicad_lib_manager/services/library_service.py | 19 ++++++++++++---- tests/test_library_manager.py | 22 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/kicad_lib_manager/services/library_service.py b/kicad_lib_manager/services/library_service.py index 7ab8d8f..95dc6ec 100644 --- a/kicad_lib_manager/services/library_service.py +++ b/kicad_lib_manager/services/library_service.py @@ -531,11 +531,11 @@ def add_entries_to_table(table_path: Path, entries: list[dict[str, str]]) -> Non # Format the entry with proper escaping entry_str = ( f" (lib " - f'(name "{entry["name"]}")' + f"(name {_quote(entry['name'])})" f'(type "KiCad")' - f'(uri "{uri}")' - f'(options "{entry["options"]}")' - f'(descr "{entry["description"]}"))\n' + f"(uri {_quote(uri)})" + f"(options {_quote(entry['options'])})" + f"(descr {_quote(entry['description'])}))\n" ) new_content += entry_str @@ -544,3 +544,14 @@ def add_entries_to_table(table_path: Path, entries: list[dict[str, str]]) -> Non # Write updated content, ensuring UTF-8 encoding with table_path.open("w", encoding="utf-8") as f: f.write(new_content) + + +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/tests/test_library_manager.py b/tests/test_library_manager.py index 47bc964..a4b26f8 100644 --- a/tests/test_library_manager.py +++ b/tests/test_library_manager.py @@ -225,3 +225,25 @@ def test_add_entries_with_special_chars(tmp_path): assert "/Users/test/žćš/footprints.pretty" in content assert "/Users/test/žćš/symbols.kicad_sym" in content assert "žćš" in content # Verify special characters are preserved + + +def test_add_entries_escapes_quotes_and_backslashes(tmp_path): + """Quotes and backslashes in entry fields keep the table well formed.""" + table_path = tmp_path / "sym-lib-table" + table_path.write_text("(sym_lib_table\n (version 7)\n)\n", encoding="utf-8") + + LibraryService.add_entries_to_table( + table_path, + [ + { + "name": "Quoted", + "uri": "C:\\libs\\a.kicad_sym", + "options": "", + "description": 'The "main"\nparts', + } + ], + ) + + content = table_path.read_text(encoding="utf-8") + assert '(uri "C:\\\\libs\\\\a.kicad_sym")' in content + assert '(descr "The \\"main\\"\\nparts")' in content From df111244c8284d73a1312e5fbe0397f8be8ea2b2 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:01:53 +0200 Subject: [PATCH 063/105] test: share the failing-write fault injection One typed helper replaces the copies in the setup and unpin tests. It fails every file opened for writing, both in-place text writes and the binary temporary file of write_atomically. --- tests/fault_injection.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_unpin_command.py | 27 +++++++++++---------------- 2 files changed, 47 insertions(+), 16 deletions(-) create mode 100644 tests/fault_injection.py 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/test_unpin_command.py b/tests/test_unpin_command.py index 13be5b1..ee56a32 100644 --- a/tests/test_unpin_command.py +++ b/tests/test_unpin_command.py @@ -1,14 +1,19 @@ """Tests for ``kilm unpin`` against a temporary KiCad configuration.""" -import io import json from pathlib import Path import pytest from typer.testing import CliRunner +from kicad_lib_manager.commands.kicad_common import ( + JsonObject, + load_json_object, + session_of, +) from kicad_lib_manager.main import app from kicad_lib_manager.services.library_service import LibraryService +from tests.fault_injection import fail_writes CONFLICT = "'--all' cannot be used with '--symbols' or '--footprints'" @@ -29,11 +34,10 @@ def kicad_common(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return path -def _session(path: Path) -> dict[str, object]: - data = json.loads(path.read_text(encoding="utf-8")) - assert isinstance(data, dict) - session = data["session"] - assert isinstance(session, dict) +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 @@ -103,16 +107,7 @@ def test_unpin_keeps_the_file_when_writing_fails( ) -> None: """A failed write leaves kicad_common.json as it was.""" before = kicad_common.read_text(encoding="utf-8") - real_open = io.open - - def failing_open(file: object, mode: str = "r", *args: object, **kwargs: object): - handle = real_open(file, mode, *args, **kwargs) - if "w" in mode: - handle.close() - raise OSError(28, "No space left on device") - return handle - - monkeypatch.setattr(io, "open", failing_open) + fail_writes(monkeypatch) result = CliRunner().invoke(app, ["unpin", "--symbols", "sym_a"]) From d45f03f19a268d077dfa237314304927c98c856c Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:01:53 +0200 Subject: [PATCH 064/105] refactor(setup): split library sources from configuration changes sources.py decides which library directories setup configures (options, KiLM configuration, environment); changes.py writes them to KiCad; command.py holds the Typer options and the order of the steps. Output is unchanged across 17 recorded scenarios. Broad exception handlers that carried on are narrowed to the errors their calls raise. --- kicad_lib_manager/commands/setup/changes.py | 230 +++++++ kicad_lib_manager/commands/setup/command.py | 640 ++++---------------- kicad_lib_manager/commands/setup/sources.py | 296 +++++++++ kicad_lib_manager/commands/shared.py | 8 +- tests/test_setup_command.py | 33 +- tests/test_sync_command.py | 12 +- 6 files changed, 679 insertions(+), 540 deletions(-) create mode 100644 kicad_lib_manager/commands/setup/changes.py create mode 100644 kicad_lib_manager/commands/setup/sources.py diff --git a/kicad_lib_manager/commands/setup/changes.py b/kicad_lib_manager/commands/setup/changes.py new file mode 100644 index 0000000..1e97ed3 --- /dev/null +++ b/kicad_lib_manager/commands/setup/changes.py @@ -0,0 +1,230 @@ +"""The changes ``kilm setup`` makes to the KiCad configuration.""" + +import re +from dataclasses import dataclass +from pathlib import Path + +from kicad_lib_manager.commands.shared import MODELS_DIR_VAR, SYMBOL_DIR_VAR +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.env_vars import ( + update_kicad_env_vars, + update_pinned_libraries, +) +from kicad_lib_manager.utils.file_ops import list_libraries, write_atomically + +SYMBOL_TABLE = "sym-lib-table" +FOOTPRINT_TABLE = "fp-lib-table" + +# 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, + *, + backup_first: bool = True, + max_backups: int = 5, + dry_run: bool = False, +) -> bool: + """Unwrap library paths written as ``${/path}`` in KiCad's library tables. + + Args: + kicad_config: The KiCad configuration directory. + backup_first: Back up each table before changing it. + 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_path in (kicad_config / SYMBOL_TABLE, kicad_config / FOOTPRINT_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 + if backup_first: + 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, "symbol"), (FOOTPRINT_TABLE, "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 f972a8a..99cc42f 100644 --- a/kicad_lib_manager/commands/setup/command.py +++ b/kicad_lib_manager/commands/setup/command.py @@ -1,82 +1,37 @@ -"""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.panel import Panel -from rich.table import Table -from kicad_lib_manager.utils.console import get_console - -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_environment, + print_source_summary, + requested_names, ) -from ...utils.file_ops import list_libraries, write_atomically -from ...utils.metadata import read_cloud_metadata, read_github_metadata - - -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) - write_atomically(table_path, fixed_content.encode("utf-8")) - - return changes_made +from kicad_lib_manager.services.library_service import LibraryService +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.env_vars import expand_user_path -def setup( +def setup( # 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)", + help="KiCad library directory (uses KICAD_USER_LIB env var if not " + "specified)", ), ] = None, kicad_3d_dir: Annotated[ @@ -84,21 +39,24 @@ def setup( typer.Option( "--kicad-3d-dir", envvar="KICAD_3D_LIB", - help="KiCad 3D models directory (uses KICAD_3D_LIB env var if not specified)", + 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)", + 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)", + help="Names of symbol libraries to use (comma-separated, uses current " + "if not specified)", ), ] = None, all_libraries: Annotated[ @@ -113,7 +71,9 @@ def setup( ] = 5, dry_run: Annotated[ bool, - typer.Option("--dry-run", help="Show what would be done without making changes"), + typer.Option( + "--dry-run", help="Show what would be done without making changes" + ), ] = False, pin_libraries: Annotated[ bool, @@ -127,7 +87,7 @@ def setup( 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: @@ -139,470 +99,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: - get_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: - get_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_environment(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]\u2705 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: - get_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: - get_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() - get_console().print(f"Looking for configuration in: {config_file}") - if config_file.exists(): - get_console().print("Configuration file exists") - else: - get_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") - + add_config_libraries(sources, selection, verbose=verbose) + except (OSError, ValueError) as e: + # Environment variables can still provide the libraries. if verbose: - get_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 + console = get_console() + console.print(f"Error reading from config: {e}") + console.print(traceback.format_exc()) - # Print what we're setting up - if config_symbol_libs: - get_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) - - get_console().print(table) - - if config_3d_libs: - get_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) - - get_console().print(table) - - except Exception as e: - # If there's any issue with config, continue with environment variables - if verbose: - get_console().print(f"Error reading from config: {e}") - import traceback - - get_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 - get_console().print( - f"[green]Using KiCad library from environment variable:[/green] [blue]{kicad_lib_dir}[/blue]" - ) - else: - get_console().print( - "[red]Error: KICAD_USER_LIB not set and not provided[/red]" - ) - get_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 - get_console().print( - f"[green]Using 3D model library from environment variable:[/green] [blue]{kicad_3d_dir}[/blue]" - ) - else: - get_console().print( - "[yellow]Warning: KICAD_3D_LIB not set, 3D models might not work correctly[/yellow]" - ) - get_console().print( - "[yellow]Consider adding a 3D model directory with 'kilm add-3d'[/yellow]" - ) - - # Show summary of where libraries are coming from - if verbose: - get_console().print("\nSummary of library sources:") - if cmd_line_lib_paths: - get_console().print(" From command line:") - for lib_type, path in cmd_line_lib_paths.items(): - get_console().print(f" - {lib_type}: {path}") - - if config_lib_paths: - get_console().print(" From config file:") - for lib_type, path in config_lib_paths.items(): - get_console().print(f" - {lib_type}: {path}") - - if env_lib_paths: - get_console().print(" From environment variables:") - for lib_type, path in env_lib_paths.items(): - get_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]" - - get_console().print() - get_console().print( + 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() - get_console().print( + 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: - get_console().print( - "[yellow]Would fix invalid library URIs in KiCad configuration[/yellow]" - ) - else: - get_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: - get_console().print(f"[red]Error finding KiCad configuration: {e}[/red]") + 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: - get_console().print( - "[yellow]Would update environment variables in KiCad configuration[/yellow]" - ) - else: - get_console().print( - "[green]Updated environment variables in KiCad configuration[/green]" - ) - get_console().print("[blue]Created backup of kicad_common.json[/blue]") - - # Show all environment variables that were set - get_console().print( - "\n[bold cyan]Environment variables set in KiCad:[/bold cyan]" - ) - for var_name, value in env_vars.items(): - get_console().print( - f" [cyan]{var_name}[/cyan] = [blue]{value}[/blue]" - ) - else: - get_console().print( - "[blue]Environment variables already up to date in KiCad configuration[/blue]" - ) - except Exception as e: - get_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 - - # Plan first: the tables are backed up only when they will change, and - # the backup must hold the content from before the change. - 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=True, + elif fixed: + console.print( + "[green]Fixed invalid library URIs in KiCad configuration[/green]" ) + return kicad_config - 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) - get_console().print( - "[blue]Created backup of symbol library table[/blue]" - ) - - if fp_table.exists(): - create_backup(fp_table, max_backups) - get_console().print( - "[blue]Created backup of footprint library table[/blue]" - ) - - 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=False, - ) - - if added_libraries: - if dry_run: - get_console().print( - f"[yellow]Would add {len(added_libraries)} libraries to KiCad configuration[/yellow]" - ) - else: - get_console().print( - f"[green]Added {len(added_libraries)} libraries to KiCad configuration[/green]" - ) - else: - get_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: - get_console().print( - f"Found {len(symbol_libs)} symbol libraries and {len(footprint_libs)} footprint libraries to pin" - ) - except Exception as e: - get_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, - max_backups=max_backups, - ) - - if pinned_changes_needed: - if dry_run: - get_console().print( - f"[yellow]Would pin {len(symbol_libs)} symbol and {len(footprint_libs)} footprint libraries in KiCad[/yellow]" - ) - else: - get_console().print( - f"[green]Pinned {len(symbol_libs)} symbol and {len(footprint_libs)} footprint libraries in KiCad[/green]" - ) - else: - get_console().print( - "[blue]All libraries already pinned in KiCad[/blue]" - ) - except Exception as e: - get_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: - get_console().print( - "[blue]No changes needed, configuration is up to date[/blue]" - ) - elif dry_run: - get_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: - get_console().print(f"[red]Error adding libraries: {e}[/red]") - if verbose: - import traceback - - get_console().print(traceback.format_exc()) - raise typer.Exit(1) from None + console.print(f"[red]Error adding libraries: {e}[/red]") + if options.verbose: + console.print(traceback.format_exc()) + raise typer.Exit(1) from e - if not dry_run and (changes_needed or env_changes_needed or pinned_changes_needed): - 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", - ) - ) + 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/sources.py b/kicad_lib_manager/commands/setup/sources.py new file mode 100644 index 0000000..e3a10fa --- /dev/null +++ b/kicad_lib_manager/commands/setup/sources.py @@ -0,0 +1,296 @@ +"""Where ``kilm setup`` finds the libraries it configures. + +The command line comes first, then the KiLM configuration, then environment +variables. +""" + +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 MODELS_DIR_VAR, SYMBOL_DIR_VAR, given +from kicad_lib_manager.services.config_service import ( + Config, + ConfigService, + LibraryDict, +) +from kicad_lib_manager.utils.console import get_console +from kicad_lib_manager.utils.env_vars import find_environment_variables +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. + from_environment: Directories found in environment variables. + """ + + 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) + from_environment: 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 = ConfigService().get_config_file_path() + 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_environment(sources: LibrarySources) -> str: + """Fill missing main directories from environment variables. + + 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_environment_variables(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.from_environment[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_environment_variables(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.from_environment[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.from_environment), + ): + 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 index 61dce72..7d37b95 100644 --- a/kicad_lib_manager/commands/shared.py +++ b/kicad_lib_manager/commands/shared.py @@ -8,6 +8,12 @@ find_environment_variables, ) +SYMBOL_DIR_VAR = "KICAD_USER_LIB" +"""The variable naming the main symbol and footprint library directory.""" + +MODELS_DIR_VAR = "KICAD_3D_LIB" +"""The variable naming the main 3D model library directory.""" + def given(value: str | None) -> str | None: """Return ``value``, or None when an option was omitted or set to ``""``. @@ -25,7 +31,7 @@ def resolve_kicad_lib_dir(option: str | None) -> str: ``find_environment_variables`` searches. Prints an error and exits with status 1 when neither names a directory. """ - directory = given(option) or given(find_environment_variables("KICAD_USER_LIB")) + directory = given(option) or given(find_environment_variables(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) diff --git a/tests/test_setup_command.py b/tests/test_setup_command.py index 003d10a..eba9715 100644 --- a/tests/test_setup_command.py +++ b/tests/test_setup_command.py @@ -1,16 +1,16 @@ """Tests for ``kilm setup`` against a temporary KiCad configuration.""" -import io import json -from collections.abc import Callable from pathlib import Path import pytest from typer.testing import CliRunner -from kicad_lib_manager.commands.setup import command as setup_command +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" @@ -29,7 +29,7 @@ def kicad_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: home.mkdir() monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("USERPROFILE", str(home)) - monkeypatch.setattr(setup_command, "find_environment_variables", lambda _name: None) + monkeypatch.setattr(setup_sources, "find_environment_variables", lambda _name: None) config = tmp_path / "kicad" config.mkdir() (config / "sym-lib-table").write_text(EMPTY_SYM_TABLE, encoding="utf-8") @@ -51,25 +51,6 @@ def library(tmp_path: Path) -> Path: return library -def _fail_writes(monkeypatch: pytest.MonkeyPatch) -> None: - """Make every file opened for writing fail after it has been opened. - - Opening with ``w`` truncates the file first, so a writer that rewrites the - target in place loses its content, while an atomic writer loses only its - temporary file. - """ - real_open: Callable[..., io.IOBase] = io.open - - def failing_open(file: object, mode: str = "r", *args: object, **kwargs: object): - handle = real_open(file, mode, *args, **kwargs) - if "w" in mode: - handle.close() - raise OSError(28, "No space left on device") - return handle - - monkeypatch.setattr(io, "open", failing_open) - - def test_setup_backs_up_library_tables_before_changing_them( kicad_config: Path, library: Path ) -> None: @@ -91,7 +72,7 @@ def test_fix_invalid_uris_rewrites_wrapped_paths(kicad_config: Path) -> None: table = kicad_config / "sym-lib-table" table.write_text(INVALID_URI_TABLE, encoding="utf-8") - assert setup_command.fix_invalid_uris(kicad_config, backup_first=False) + assert setup_changes.fix_invalid_uris(kicad_config, backup_first=False) assert '(uri "/libs/symbols/Bad.kicad_sym")' in table.read_text(encoding="utf-8") @@ -102,10 +83,10 @@ def test_fix_invalid_uris_keeps_the_table_when_writing_fails( """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) + fail_writes(monkeypatch) with pytest.raises(OSError, match="No space left"): - setup_command.fix_invalid_uris(kicad_config, backup_first=False) + setup_changes.fix_invalid_uris(kicad_config, backup_first=False) assert table.read_text(encoding="utf-8") == INVALID_URI_TABLE diff --git a/tests/test_sync_command.py b/tests/test_sync_command.py index 4bdfae0..edce427 100644 --- a/tests/test_sync_command.py +++ b/tests/test_sync_command.py @@ -7,7 +7,7 @@ import pytest from typer.testing import CliRunner -from kicad_lib_manager.commands.setup import command as setup_command +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 @@ -81,7 +81,8 @@ def library(tmp_path: Path, upstream: Path, monkeypatch: pytest.MonkeyPatch) -> return clone -def test_sync_reports_an_up_to_date_library(library: Path) -> None: +@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"]) @@ -133,13 +134,12 @@ def test_sync_dry_run_does_not_pull(upstream: Path, library: Path) -> None: assert not (library / "symbols").exists() +@pytest.mark.usefixtures("library") def test_sync_auto_setup_runs_setup_with_its_defaults( - upstream: Path, library: Path, monkeypatch: pytest.MonkeyPatch + upstream: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Setup runs with its option defaults, so it looks up the library itself.""" - monkeypatch.setattr( - setup_command, "find_environment_variables", lambda _name: None - ) + monkeypatch.setattr(setup_sources, "find_environment_variables", lambda _name: None) _commit_file(upstream, "symbols/New.kicad_sym", "add symbols") result = CliRunner().invoke(app, ["sync", "--auto-setup"]) From ffe8a912cba212c870b7c75f7ceca04d8d176ce5 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:02:20 +0200 Subject: [PATCH 065/105] test: document the command tests and type their helpers --- tests/test_command_docs.py | 3 ++- tests/test_init_command.py | 4 +++- tests/test_leaf_command_usage.py | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) 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_init_command.py b/tests/test_init_command.py index 7dd128a..7d25c44 100644 --- a/tests/test_init_command.py +++ b/tests/test_init_command.py @@ -25,7 +25,9 @@ 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"}) + 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 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" From 0cbd1462a2960b210cf8858fc48e5422d1646332 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:02:55 +0200 Subject: [PATCH 066/105] fix: drop the emoji from success panel titles The project keeps CLI output free of emoji; setup, init, pin, and unpin put U+2705 in their panel titles. --- kicad_lib_manager/commands/init/command.py | 2 +- kicad_lib_manager/commands/pin/command.py | 2 +- kicad_lib_manager/commands/setup/command.py | 2 +- kicad_lib_manager/commands/unpin/command.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/kicad_lib_manager/commands/init/command.py b/kicad_lib_manager/commands/init/command.py index 483bb00..feeb216 100644 --- a/kicad_lib_manager/commands/init/command.py +++ b/kicad_lib_manager/commands/init/command.py @@ -211,7 +211,7 @@ def init( # noqa: PLR0913 -- each CLI option is a Typer parameter metadata.get("env_var"), set_current=set_current, ), - title="✅ Initialization Complete", + title="Initialization Complete", border_style="green", ) ) diff --git a/kicad_lib_manager/commands/pin/command.py b/kicad_lib_manager/commands/pin/command.py index b1d2138..1851576 100644 --- a/kicad_lib_manager/commands/pin/command.py +++ b/kicad_lib_manager/commands/pin/command.py @@ -88,7 +88,7 @@ def _report( 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", + title="Libraries Pinned", border_style="green", ) ) diff --git a/kicad_lib_manager/commands/setup/command.py b/kicad_lib_manager/commands/setup/command.py index 99cc42f..f000068 100644 --- a/kicad_lib_manager/commands/setup/command.py +++ b/kicad_lib_manager/commands/setup/command.py @@ -134,7 +134,7 @@ def setup( # noqa: PLR0913 -- each CLI option is a Typer parameter Panel( "[bold green]Setup complete! Restart KiCad for changes to take " "effect.[/bold green]", - title="[bold green]\u2705 Success[/bold green]", + title="[bold green]Success[/bold green]", border_style="green", ) ) diff --git a/kicad_lib_manager/commands/unpin/command.py b/kicad_lib_manager/commands/unpin/command.py index d5577f0..08f99d0 100644 --- a/kicad_lib_manager/commands/unpin/command.py +++ b/kicad_lib_manager/commands/unpin/command.py @@ -120,7 +120,7 @@ def _report( 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", + title="Libraries Unpinned", border_style="green", ) ) From 5cec99a3d538157e14c5be6d9b5942adc23e2da8 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:03:27 +0200 Subject: [PATCH 067/105] docs(sync): describe how sync detects new library content --- kicad_lib_manager/commands/sync/docs.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 5cff1e53f557df5d4240085b41aa23193af8ceff Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:03:38 +0200 Subject: [PATCH 068/105] refactor(services): split the library service and pass ruff ALL and strict ty kicad_config.find_kicad_config takes the platform, home, and environment as optional arguments so tests need not patch them. library_table holds URI formatting, table writing, and add_libraries; tables are replaced atomically, library_descriptions.yaml is read once per call, and a missing additional 3D directory is reported on stderr. LibraryService keeps the methods commands call (initialize_library, list_libraries, add_libraries, find_kicad_config); initialize_library returns the typed GitHubMetadata and writes it once. KiCadService keeps find_kicad_config_dir and get_configured_libraries. Removed as unused: the interfaces package (no implementation or caller used its protocols), LibraryService.get_library_metadata, pin_libraries, and the unpin_libraries stub, and KiCadService's environment variable, pinned library, and add_libraries_to_kicad methods (the last passed the KiCad configuration directory as the library directory). --- AGENTS.md | 4 +- kicad_lib_manager/interfaces/__init__.py | 1 - .../interfaces/config_service.py | 53 -- kicad_lib_manager/interfaces/kicad_service.py | 56 -- .../interfaces/library_service.py | 70 --- kicad_lib_manager/services/__init__.py | 2 +- kicad_lib_manager/services/kicad_config.py | 85 +++ kicad_lib_manager/services/kicad_service.py | 104 +-- kicad_lib_manager/services/library_service.py | 594 ++---------------- kicad_lib_manager/services/library_table.py | 261 ++++++++ tests/test_kicad_config.py | 68 ++ tests/test_library_manager.py | 249 -------- tests/test_library_service.py | 56 ++ tests/test_library_table.py | 188 ++++++ 14 files changed, 743 insertions(+), 1048 deletions(-) delete mode 100644 kicad_lib_manager/interfaces/__init__.py delete mode 100644 kicad_lib_manager/interfaces/config_service.py delete mode 100644 kicad_lib_manager/interfaces/kicad_service.py delete mode 100644 kicad_lib_manager/interfaces/library_service.py create mode 100644 kicad_lib_manager/services/kicad_config.py create mode 100644 kicad_lib_manager/services/library_table.py create mode 100644 tests/test_kicad_config.py delete mode 100644 tests/test_library_manager.py create mode 100644 tests/test_library_service.py create mode 100644 tests/test_library_table.py diff --git a/AGENTS.md b/AGENTS.md index eb3954d..eaa8b01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,8 +39,8 @@ files; tests run in `just check`. `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/services/`: configuration, KiCad, library, and + self-update services. - `kicad_lib_manager/utils/`: file operations, backups, metadata, templates, environment variables, git helpers. - `tests/`: pytest suite. 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 f90521d..0000000 --- a/kicad_lib_manager/interfaces/config_service.py +++ /dev/null @@ -1,53 +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 e77bf6b..0000000 --- a/kicad_lib_manager/interfaces/kicad_service.py +++ /dev/null @@ -1,56 +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 16c6785..0000000 --- a/kicad_lib_manager/interfaces/library_service.py +++ /dev/null @@ -1,70 +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/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/kicad_config.py b/kicad_lib_manager/services/kicad_config.py new file mode 100644 index 0000000..b01ebf8 --- /dev/null +++ b/kicad_lib_manager/services/kicad_config.py @@ -0,0 +1,85 @@ +"""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 + +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), entry.name)) + if ( + not (latest / "sym-lib-table").exists() + and not (latest / "fp-lib-table").exists() + ): + msg = f"KiCad library tables not found in {latest}. {RUN_KICAD_FIRST}" + raise FileNotFoundError(msg) + return latest + + +def _version_key(version_dir: Path) -> tuple[int, ...]: + """Return the numbers in a version directory name, for numeric ordering.""" + return tuple(int(part) for part in re.findall(r"\d+", version_dir.name)) diff --git a/kicad_lib_manager/services/kicad_service.py b/kicad_lib_manager/services/kicad_service.py index 234335f..ae68a5f 100644 --- a/kicad_lib_manager/services/kicad_service.py +++ b/kicad_lib_manager/services/kicad_service.py @@ -1,100 +1,32 @@ -"""KiCad service implementation for KiCad Library Manager.""" +"""KiCad configuration lookups used by the pin and status commands.""" -import json from pathlib import Path -from ..utils.file_ops import list_configured_libraries -from .library_service import LibraryService +from kicad_lib_manager.services.library_service import LibraryService +from kicad_lib_manager.utils.file_ops import ( + LibraryTableEntry, + list_configured_libraries, +) class KiCadService: - """Service for managing KiCad configuration and libraries.""" + """Finds KiCad's configuration and the libraries its tables list.""" @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 the newest KiCad version's configuration directory. - return {} + Goes through `LibraryService.find_kicad_config` so that one patch + point covers every command. - 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) + Raises: + FileNotFoundError: KiCad's configuration cannot be found. + """ + return LibraryService.find_kicad_config() + @staticmethod 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 [], [] + ) -> tuple[list[LibraryTableEntry], list[LibraryTableEntry]]: + """Return the entries of KiCad's symbol and footprint library tables.""" + return list_configured_libraries(config_dir) diff --git a/kicad_lib_manager/services/library_service.py b/kicad_lib_manager/services/library_service.py index 95dc6ec..7debd1d 100644 --- a/kicad_lib_manager/services/library_service.py +++ b/kicad_lib_manager/services/library_service.py @@ -1,557 +1,91 @@ -"""Library service implementation for KiCad Library Manager. -Core functionality for managing KiCad libraries. -""" +"""Create KiLM libraries and register their contents with KiCad.""" -# PEP 810: on Python 3.15+ these imports load on first use. -__lazy_modules__ = {"yaml"} - -import os -import platform -import re from pathlib import Path +from typing import Final -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.file_ops import list_libraries +from kicad_lib_manager.utils.metadata import ( + GitHubMetadata, generate_env_var_name, get_default_github_metadata, read_github_metadata, write_github_metadata, ) +LIBRARY_FOLDERS: Final = ("symbols", "footprints", "templates") +"""The top-level folders of a KiLM library, created by `initialize_library`.""" + class LibraryService: - """Service for managing KiCad libraries.""" + """Library operations used by the init, list, pin, setup, and unpin commands. + + `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. + """ + + add_libraries = staticmethod(add_libraries) + find_kicad_config = staticmethod(find_kicad_config) + + @staticmethod + def list_libraries(directory: Path) -> tuple[list[str], list[str]]: + """Return the symbol and footprint library names in a KiLM library. - def list_libraries(self, directory: Path) -> tuple[list[str], list[str]]: - """List symbol and footprint libraries in a directory.""" + Raises: + FileNotFoundError: directory does not exist. + """ return list_libraries(str(directory)) - def initialize_library( - self, + @staticmethod + 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, - ) -> dict: - """Initialize a library in the given directory.""" - # Check for existing metadata - metadata = read_github_metadata(directory) + ) -> GitHubMetadata: + """Make directory a KiLM library and return its metadata. - 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"] + 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. - if any([name, description, env_var, no_env_var]): - metadata["updated_with"] = "kilm" - write_github_metadata(directory, metadata) - else: - # Create new metadata + Raises: + OSError: A folder cannot be created. + """ + overrides = (name, description, env_var) + existing = read_github_metadata(directory) + if force or existing is None or not existing: 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: + metadata = existing + if no_env_var or any(value is not None for value in overrides): + metadata["updated_with"] = "kilm" - 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(), + 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) + + for folder in LIBRARY_FOLDERS: + (directory / folder).mkdir(parents=True, exist_ok=True) + # Every folder exists now, so the library can hold each kind. + metadata["capabilities"] = { + "symbols": True, + "footprints": True, + "templates": True, } - 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) - - # Add new entries to footprint table - if fp_changes_needed: - LibraryService.add_entries_to_table(fp_table, new_fp_entries) - - # Return the set of added libraries - added_libraries = set(new_symbols + new_footprints) - return added_libraries, changes_needed - - @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 - - Raises: - FileNotFoundError: If KiCad configuration directory is not found - """ - 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" - return f"{lib_name} footprint library" - - @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) - - Returns: - The formatted URI string - - Raises: - ValueError: If base_path is empty, lib_type is invalid, or path format is invalid - """ - 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 - # If it's not in ${...} format, check if it's an absolute path - elif 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" - 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 {_quote(entry['name'])})" - f'(type "KiCad")' - f"(uri {_quote(uri)})" - f"(options {_quote(entry['options'])})" - f"(descr {_quote(entry['description'])}))\n" - ) - new_content += entry_str - - new_content += line + "\n" - - # Write updated content, ensuring UTF-8 encoding - with table_path.open("w", encoding="utf-8") as f: - f.write(new_content) - - -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/library_table.py b/kicad_lib_manager/services/library_table.py new file mode 100644 index 0000000..fc3b003 --- /dev/null +++ b/kicad_lib_manager/services/library_table.py @@ -0,0 +1,261 @@ +"""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.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": "sym-lib-table", + "footprints": "fp-lib-table", +} + +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/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_library_manager.py b/tests/test_library_manager.py deleted file mode 100644 index a4b26f8..0000000 --- a/tests/test_library_manager.py +++ /dev/null @@ -1,249 +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 - - -def test_add_entries_escapes_quotes_and_backslashes(tmp_path): - """Quotes and backslashes in entry fields keep the table well formed.""" - table_path = tmp_path / "sym-lib-table" - table_path.write_text("(sym_lib_table\n (version 7)\n)\n", encoding="utf-8") - - LibraryService.add_entries_to_table( - table_path, - [ - { - "name": "Quoted", - "uri": "C:\\libs\\a.kicad_sym", - "options": "", - "description": 'The "main"\nparts', - } - ], - ) - - content = table_path.read_text(encoding="utf-8") - assert '(uri "C:\\\\libs\\\\a.kicad_sym")' in content - assert '(descr "The \\"main\\"\\nparts")' in content diff --git a/tests/test_library_service.py b/tests/test_library_service.py new file mode 100644 index 0000000..1330ce0 --- /dev/null +++ b/tests/test_library_service.py @@ -0,0 +1,56 @@ +"""Tests for creating KiLM libraries.""" + +from pathlib import Path + +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_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" diff --git a/tests/test_library_table.py b/tests/test_library_table.py new file mode 100644 index 0000000..fe6ca97 --- /dev/null +++ b/tests/test_library_table.py @@ -0,0 +1,188 @@ +"""Tests for adding KiLM libraries to KiCad's library tables.""" + +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_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.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) From ac9b576f9c12b77d31b16c6966996e54bac306c6 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:03:52 +0200 Subject: [PATCH 069/105] fix(init): fail when kilm.yaml cannot be written initialize_library ignored write_github_metadata's failure, so kilm init went on to register the library and report success without metadata. It now raises OSError, which kilm init reports with exit status 1. --- kicad_lib_manager/services/library_service.py | 7 +++++-- tests/test_library_service.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/kicad_lib_manager/services/library_service.py b/kicad_lib_manager/services/library_service.py index 7debd1d..d5d7f5b 100644 --- a/kicad_lib_manager/services/library_service.py +++ b/kicad_lib_manager/services/library_service.py @@ -5,6 +5,7 @@ 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 ( GitHubMetadata, @@ -57,7 +58,7 @@ def initialize_library( # noqa: PLR0913 -- one parameter per init command optio capabilities and metadata file reflect them. Raises: - OSError: A folder cannot be created. + OSError: A folder or the metadata file cannot be written. """ overrides = (name, description, env_var) existing = read_github_metadata(directory) @@ -87,5 +88,7 @@ def initialize_library( # noqa: PLR0913 -- one parameter per init command optio "footprints": True, "templates": True, } - write_github_metadata(directory, metadata) + if not write_github_metadata(directory, metadata): + msg = f"Could not write {GITHUB_METADATA_FILE} in {directory}" + raise OSError(msg) return metadata diff --git a/tests/test_library_service.py b/tests/test_library_service.py index 1330ce0..113be07 100644 --- a/tests/test_library_service.py +++ b/tests/test_library_service.py @@ -2,6 +2,9 @@ from pathlib import Path +import pytest + +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 @@ -54,3 +57,15 @@ def test_force_replaces_existing_metadata(tmp_path: Path) -> None: 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: False + ) + + with pytest.raises(OSError, match=r"kilm\.yaml"): + LibraryService.initialize_library(tmp_path) From 50a3e2c49af11f2b99abdc7de74b7b07df57ef3e Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:06:04 +0200 Subject: [PATCH 070/105] fix(config): keep ConfigService.get_config_file_path for setup strict/commands' setup reads the config file path through ConfigService.get_config_file_path, which the config refactor had removed. The shim returns Config.config_file until setup uses Config directly. --- kicad_lib_manager/services/config_service.py | 9 +++++++-- tests/test_config_service.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/kicad_lib_manager/services/config_service.py b/kicad_lib_manager/services/config_service.py index ee44b72..3291d65 100644 --- a/kicad_lib_manager/services/config_service.py +++ b/kicad_lib_manager/services/config_service.py @@ -258,11 +258,16 @@ def _library_entry(entry: object) -> LibraryDict | None: class ConfigService: - """Holder of a `Config`, kept only because main.py still constructs it. + """Holder of a `Config` for callers that predate `Config`'s public API. - New code uses `Config` directly; remove this once main.py does. + main.py reads `_config` and setup calls `get_config_file_path`. New code + uses `Config` directly; remove this once those callers do. """ def __init__(self) -> None: """Load the user's configuration into `_config`.""" self._config = Config() + + def get_config_file_path(self) -> Path: + """Return the configuration file that `_config` reads and saves.""" + return self._config.config_file diff --git a/tests/test_config_service.py b/tests/test_config_service.py index 8c551ac..09ab377 100644 --- a/tests/test_config_service.py +++ b/tests/test_config_service.py @@ -10,6 +10,7 @@ from kicad_lib_manager.services.config_service import ( LAST_UPDATE_CHECK_FILE, Config, + ConfigService, LibraryDict, ) @@ -224,3 +225,14 @@ def test_marking_a_check_postpones_the_next(config_file: Path) -> None: config.mark_update_check_performed() assert not config.should_check_updates() + + +def test_config_service_reports_the_user_config_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The ConfigService shim exposes the file its Config reads.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + assert ConfigService().get_config_file_path() == ( + tmp_path / ".config" / "kicad-lib-manager" / "config.yaml" + ) From 786e88420127a4490299354640cc5c3373fde6bd Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:07:55 +0200 Subject: [PATCH 071/105] refactor: type add-3d and status with the typed metadata and table entries --- kicad_lib_manager/commands/add_3d/command.py | 9 +++++---- kicad_lib_manager/commands/status/command.py | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/kicad_lib_manager/commands/add_3d/command.py b/kicad_lib_manager/commands/add_3d/command.py index cb12ac6..6a7ff2b 100644 --- a/kicad_lib_manager/commands/add_3d/command.py +++ b/kicad_lib_manager/commands/add_3d/command.py @@ -15,6 +15,7 @@ from kicad_lib_manager.utils.console import get_console from kicad_lib_manager.utils.metadata import ( CLOUD_METADATA_FILE, + CloudMetadata, generate_env_var_name, get_default_cloud_metadata, read_cloud_metadata, @@ -23,8 +24,6 @@ MODEL_EXTENSIONS = (".step", ".stp", ".wrl", ".wings") -CloudMetadata = dict[str, str | int | None] - @dataclass(frozen=True) class _Overrides: @@ -84,8 +83,10 @@ def _update_existing( console.print("[yellow]Disabling environment variable as requested[/yellow]") if overrides.given(): - metadata["name"] = name - metadata["description"] = description + if name is not None: + metadata["name"] = name + if description is not None: + metadata["description"] = description metadata["env_var"] = ( env_var if _present(env_var) and not overrides.no_env_var else None ) diff --git a/kicad_lib_manager/commands/status/command.py b/kicad_lib_manager/commands/status/command.py index d98dc56..100e34d 100644 --- a/kicad_lib_manager/commands/status/command.py +++ b/kicad_lib_manager/commands/status/command.py @@ -31,6 +31,7 @@ from kicad_lib_manager.services.kicad_service import KiCadService from kicad_lib_manager.utils.console import get_console from kicad_lib_manager.utils.constants import CONFIG_DIR_NAME, CONFIG_FILE_NAME +from kicad_lib_manager.utils.file_ops import LibraryTableEntry from kicad_lib_manager.utils.metadata import read_cloud_metadata, read_github_metadata LEGACY_PINNED_FILE = "pinned" @@ -254,7 +255,7 @@ def _show_legacy_pinned_libraries(pinned_file: Path) -> None: ) -def _print_configured(kind: str, libraries: list[dict[str, str]]) -> None: +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]") @@ -265,7 +266,7 @@ def _print_configured(kind: str, libraries: list[dict[str, str]]) -> None: table.add_column("Library Name", style="cyan", no_wrap=True) table.add_column("URI", style="blue") for library in libraries: - table.add_row(library.get("name", ""), library.get("uri", "")) + table.add_row(library["name"], library.get("uri", "")) console.print(table) From 87b5ff8ad4c629a5ede5f4c4663478dc53f96ac2 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:08:57 +0200 Subject: [PATCH 072/105] refactor: drop the ConfigService shim and duplicate kicad_common constants --- kicad_lib_manager/commands/kicad_common.py | 4 +--- kicad_lib_manager/commands/setup/sources.py | 8 ++------ kicad_lib_manager/commands/status/command.py | 8 ++++---- kicad_lib_manager/commands/unpin/command.py | 2 +- kicad_lib_manager/services/config_service.py | 20 -------------------- kicad_lib_manager/utils/constants.py | 1 - tests/test_config_service.py | 12 ------------ tests/test_unpin_command.py | 7 ++----- 8 files changed, 10 insertions(+), 52 deletions(-) diff --git a/kicad_lib_manager/commands/kicad_common.py b/kicad_lib_manager/commands/kicad_common.py index 0ce3e35..964b9dc 100644 --- a/kicad_lib_manager/commands/kicad_common.py +++ b/kicad_lib_manager/commands/kicad_common.py @@ -10,14 +10,12 @@ import json from pathlib import Path +from kicad_lib_manager.utils.env_vars import JsonObject from kicad_lib_manager.utils.file_ops import write_atomically -KICAD_COMMON_FILE = "kicad_common.json" PINNED_SYMBOLS_KEY = "pinned_symbol_libs" PINNED_FOOTPRINTS_KEY = "pinned_fp_libs" -JsonObject = dict[str, object] - def as_json_object(value: object) -> JsonObject | None: """Return ``value`` as a JSON object, or None when it is not one.""" diff --git a/kicad_lib_manager/commands/setup/sources.py b/kicad_lib_manager/commands/setup/sources.py index e3a10fa..f73cf38 100644 --- a/kicad_lib_manager/commands/setup/sources.py +++ b/kicad_lib_manager/commands/setup/sources.py @@ -13,11 +13,7 @@ from rich.table import Table from kicad_lib_manager.commands.shared import MODELS_DIR_VAR, SYMBOL_DIR_VAR, given -from kicad_lib_manager.services.config_service import ( - Config, - ConfigService, - LibraryDict, -) +from kicad_lib_manager.services.config_service import Config, LibraryDict from kicad_lib_manager.utils.console import get_console from kicad_lib_manager.utils.env_vars import find_environment_variables from kicad_lib_manager.utils.metadata import read_cloud_metadata, read_github_metadata @@ -122,7 +118,7 @@ def add_config_libraries( console = get_console() config = Config() if verbose: - config_file = ConfigService().get_config_file_path() + 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}") diff --git a/kicad_lib_manager/commands/status/command.py b/kicad_lib_manager/commands/status/command.py index 100e34d..e6886fa 100644 --- a/kicad_lib_manager/commands/status/command.py +++ b/kicad_lib_manager/commands/status/command.py @@ -18,10 +18,8 @@ from rich.text import Text from kicad_lib_manager.commands.kicad_common import ( - KICAD_COMMON_FILE, PINNED_FOOTPRINTS_KEY, PINNED_SYMBOLS_KEY, - JsonObject, as_json_object, as_list, load_json_object, @@ -29,8 +27,10 @@ session_of, ) from kicad_lib_manager.services.kicad_service import KiCadService +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 CONFIG_DIR_NAME, CONFIG_FILE_NAME +from kicad_lib_manager.utils.constants import KICAD_COMMON_FILE +from kicad_lib_manager.utils.env_vars import JsonObject from kicad_lib_manager.utils.file_ops import LibraryTableEntry from kicad_lib_manager.utils.metadata import read_cloud_metadata, read_github_metadata @@ -72,7 +72,7 @@ def _show_kilm_configuration() -> None: """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 + config_file = user_config_file() exists = config_file.exists() except (OSError, RuntimeError) as e: console.print(f"[red]Error reading KILM configuration: {e}[/red]") diff --git a/kicad_lib_manager/commands/unpin/command.py b/kicad_lib_manager/commands/unpin/command.py index 08f99d0..4c0abc2 100644 --- a/kicad_lib_manager/commands/unpin/command.py +++ b/kicad_lib_manager/commands/unpin/command.py @@ -7,7 +7,6 @@ from rich.panel import Panel from kicad_lib_manager.commands.kicad_common import ( - KICAD_COMMON_FILE, PINNED_FOOTPRINTS_KEY, PINNED_SYMBOLS_KEY, as_list, @@ -19,6 +18,7 @@ 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 KICAD_COMMON_FILE NO_SESSION = ( "[yellow]No session information found in KiCad configuration, " diff --git a/kicad_lib_manager/services/config_service.py b/kicad_lib_manager/services/config_service.py index 3291d65..0f44c5a 100644 --- a/kicad_lib_manager/services/config_service.py +++ b/kicad_lib_manager/services/config_service.py @@ -93,10 +93,6 @@ def _load_config(self) -> None: self._config.get("libraries") ) - def _get_config_file(self) -> Path: - """Return `config_file`; kept for callers that predate the attribute.""" - return self.config_file - def get(self, key: str, default: ConfigValue | None = None) -> ConfigValue | None: """Return the setting `key`, or `default` when it is not set.""" return self._config.get(key, default) @@ -255,19 +251,3 @@ def _library_entry(entry: object) -> LibraryDict | None: return LibraryDict( name=str(fields["name"]), path=str(fields["path"]), type=str(fields["type"]) ) - - -class ConfigService: - """Holder of a `Config` for callers that predate `Config`'s public API. - - main.py reads `_config` and setup calls `get_config_file_path`. New code - uses `Config` directly; remove this once those callers do. - """ - - def __init__(self) -> None: - """Load the user's configuration into `_config`.""" - self._config = Config() - - def get_config_file_path(self) -> Path: - """Return the configuration file that `_config` reads and saves.""" - return self._config.config_file diff --git a/kicad_lib_manager/utils/constants.py b/kicad_lib_manager/utils/constants.py index 38129e1..20121a1 100644 --- a/kicad_lib_manager/utils/constants.py +++ b/kicad_lib_manager/utils/constants.py @@ -23,4 +23,3 @@ # Default configuration values DEFAULT_MAX_BACKUPS = 5 -DEFAULT_LIBRARIES: list = [] # ty: ignore[missing-type-argument] -- element type lives in config_service, which imports this module; inline [] there and drop this constant diff --git a/tests/test_config_service.py b/tests/test_config_service.py index 09ab377..8c551ac 100644 --- a/tests/test_config_service.py +++ b/tests/test_config_service.py @@ -10,7 +10,6 @@ from kicad_lib_manager.services.config_service import ( LAST_UPDATE_CHECK_FILE, Config, - ConfigService, LibraryDict, ) @@ -225,14 +224,3 @@ def test_marking_a_check_postpones_the_next(config_file: Path) -> None: config.mark_update_check_performed() assert not config.should_check_updates() - - -def test_config_service_reports_the_user_config_file( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The ConfigService shim exposes the file its Config reads.""" - monkeypatch.setattr(Path, "home", lambda: tmp_path) - - assert ConfigService().get_config_file_path() == ( - tmp_path / ".config" / "kicad-lib-manager" / "config.yaml" - ) diff --git a/tests/test_unpin_command.py b/tests/test_unpin_command.py index ee56a32..b72d45a 100644 --- a/tests/test_unpin_command.py +++ b/tests/test_unpin_command.py @@ -6,13 +6,10 @@ import pytest from typer.testing import CliRunner -from kicad_lib_manager.commands.kicad_common import ( - JsonObject, - load_json_object, - session_of, -) +from kicad_lib_manager.commands.kicad_common import load_json_object, session_of from kicad_lib_manager.main import app from kicad_lib_manager.services.library_service import LibraryService +from kicad_lib_manager.utils.env_vars import JsonObject from tests.fault_injection import fail_writes CONFLICT = "'--all' cannot be used with '--symbols' or '--footprints'" From 61b474e3f6efe3a292e326e213e7db21a6f4ae19 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:09:19 +0200 Subject: [PATCH 073/105] refactor: make dry_run and create_if_missing keyword-only --- kicad_lib_manager/utils/env_vars.py | 3 ++- kicad_lib_manager/utils/file_ops.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/kicad_lib_manager/utils/env_vars.py b/kicad_lib_manager/utils/env_vars.py index a665494..6618f3f 100644 --- a/kicad_lib_manager/utils/env_vars.py +++ b/kicad_lib_manager/utils/env_vars.py @@ -135,7 +135,8 @@ def expand_user_path(path: str) -> str: def update_kicad_env_vars( kicad_config: Path, env_vars: Mapping[str, str | None], - dry_run: bool = False, # noqa: FBT001, FBT002 -- setup and KiCadService pass it positionally; make it keyword-only once they pass it by name + *, + dry_run: bool = False, max_backups: int = DEFAULT_MAX_BACKUPS, ) -> bool: """Set or remove environment variables in kicad_common.json. diff --git a/kicad_lib_manager/utils/file_ops.py b/kicad_lib_manager/utils/file_ops.py index bb66967..6802669 100644 --- a/kicad_lib_manager/utils/file_ops.py +++ b/kicad_lib_manager/utils/file_ops.py @@ -108,7 +108,8 @@ def write_atomically(path: Path, data: bytes) -> None: def validate_lib_table( table_path: Path, - create_if_missing: bool = True, # noqa: FBT001, FBT002 -- LibraryService passes it positionally; make it keyword-only once it passes it by name + *, + create_if_missing: bool = True, ) -> None: """Check that table_path is a KiCad library table, creating an empty one if allowed. From 597b67d1b33ee6b1e9276d0d2a2fece94f4373fd Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:09:58 +0200 Subject: [PATCH 074/105] chore: record the measured coverage floor and the new layout --- AGENTS.md | 16 ++++++++++++---- pyproject.toml | 4 ++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eaa8b01..443386a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,12 +38,15 @@ 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. + 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` and `kicad_common.py`: option helpers + and typed `kicad_common.json` access shared by several commands. - `kicad_lib_manager/services/`: configuration, KiCad, library, and self-update services. -- `kicad_lib_manager/utils/`: file operations, backups, metadata, templates, - environment variables, git helpers. -- `tests/`: pytest suite. +- `kicad_lib_manager/utils/`: file operations, backups, metadata, the + `template/` package, environment variables, git helpers, and `console.py`. +- `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 @@ -70,6 +73,11 @@ files; tests run in `just check`. 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/pyproject.toml b/pyproject.toml index a329503..ff61c1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -167,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", From 7ec1c3af4ca49ae823a525ab646c30bcc006bfd7 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:12:35 +0200 Subject: [PATCH 075/105] fix(tests): keep CLI tests out of the real home and off the network --- tests/conftest.py | 29 +++++++++++++++++++++++++++++ tests/test_isolation.py | 20 ++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_isolation.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6a8e5e2 --- /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 _isolated_user( + tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch +) -> Path: + """Give each test an empty home 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/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) From 06ba73387b24f40a0914586b2ce923f115e06e4c Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:14:01 +0200 Subject: [PATCH 076/105] fix: keep pathspec and packaging out of CLI startup on Python 3.15 --- kicad_lib_manager/commands/update/perform.py | 3 ++ kicad_lib_manager/utils/template/authoring.py | 3 ++ kicad_lib_manager/utils/template/selection.py | 11 +++++-- tests/test_startup_imports.py | 32 +++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/test_startup_imports.py diff --git a/kicad_lib_manager/commands/update/perform.py b/kicad_lib_manager/commands/update/perform.py index 4f4cdd1..671c7c7 100644 --- a/kicad_lib_manager/commands/update/perform.py +++ b/kicad_lib_manager/commands/update/perform.py @@ -1,5 +1,8 @@ """`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 diff --git a/kicad_lib_manager/utils/template/authoring.py b/kicad_lib_manager/utils/template/authoring.py index 83e1486..28baefa 100644 --- a/kicad_lib_manager/utils/template/authoring.py +++ b/kicad_lib_manager/utils/template/authoring.py @@ -1,5 +1,8 @@ """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 diff --git a/kicad_lib_manager/utils/template/selection.py b/kicad_lib_manager/utils/template/selection.py index 6c2b80e..6d01458 100644 --- a/kicad_lib_manager/utils/template/selection.py +++ b/kicad_lib_manager/utils/template/selection.py @@ -12,10 +12,14 @@ 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, @@ -184,8 +188,11 @@ class _ExclusionRules: source: Path template_root: Path | None - gitignore: pathspec.PathSpec | None - additional: pathspec.PathSpec | 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()}/" 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() == [] From 556e10e592bb3227230b8931624656fdbf3aa7ce Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:21:32 +0200 Subject: [PATCH 077/105] fix(template): point empty-list hints at kilm config list --- kicad_lib_manager/commands/template/listing.py | 4 ++-- tests/test_template_commands.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/kicad_lib_manager/commands/template/listing.py b/kicad_lib_manager/commands/template/listing.py index 311f4b2..a6de148 100644 --- a/kicad_lib_manager/commands/template/listing.py +++ b/kicad_lib_manager/commands/template/listing.py @@ -99,7 +99,7 @@ def _print_no_templates(library: str | None) -> None: f"[yellow]No templates found in library '[cyan]{library}[/cyan]'." "[/yellow]\n\n" "[cyan]Try:[/cyan]\n" - "• List all libraries: [blue]kilm list-libraries[/blue]\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]", @@ -110,7 +110,7 @@ def _print_no_templates(library: str | None) -> None: "[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]", + "• Check library configuration: [blue]kilm config list[/blue]", title="[bold yellow]No Templates[/bold yellow]", border_style="yellow", ) diff --git a/tests/test_template_commands.py b/tests/test_template_commands.py index da93988..ae9eee5 100644 --- a/tests/test_template_commands.py +++ b/tests/test_template_commands.py @@ -198,3 +198,16 @@ def test_make_dry_run_lists_only_files_that_make_copies( 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 From d16ae23efe9f80e66f792ac226129c70d2547b15 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:21:58 +0200 Subject: [PATCH 078/105] fix(status): describe library state in words instead of check marks --- kicad_lib_manager/commands/status/command.py | 4 ++-- tests/test_repository_limits.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/kicad_lib_manager/commands/status/command.py b/kicad_lib_manager/commands/status/command.py index e6886fa..0447d3a 100644 --- a/kicad_lib_manager/commands/status/command.py +++ b/kicad_lib_manager/commands/status/command.py @@ -120,8 +120,8 @@ def _show_library_table( table.add_row( str(library.get("name", "unnamed")), path, - "✓ Current" if current_library == path else "", - "✓" if metadata is not None and metadata else "✗", + "Current" if current_library == path else "", + "Yes" if metadata is not None and metadata else "Missing", ) get_console().print(table) diff --git a/tests/test_repository_limits.py b/tests/test_repository_limits.py index baaa6a9..d4ce1ce 100644 --- a/tests/test_repository_limits.py +++ b/tests/test_repository_limits.py @@ -4,6 +4,7 @@ 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 @@ -20,6 +21,9 @@ 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]: @@ -56,3 +60,16 @@ def test_suppressions_name_rule_and_reason() -> None: 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}" From 5d2dd02b09a9c0b706b5356ea2221795a2c360d8 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:22:20 +0200 Subject: [PATCH 079/105] docs: name the 3D model metadata file correctly --- kicad_lib_manager/commands/add_3d/docs.mdx | 4 ++-- kicad_lib_manager/commands/setup/docs.mdx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kicad_lib_manager/commands/add_3d/docs.mdx b/kicad_lib_manager/commands/add_3d/docs.mdx index f583443..5d6c26b 100644 --- a/kicad_lib_manager/commands/add_3d/docs.mdx +++ b/kicad_lib_manager/commands/add_3d/docs.mdx @@ -29,7 +29,7 @@ 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 @@ -155,5 +155,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/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 From 5846302b5f1af879f0b7f45752dcd480f9846416 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:37:53 +0200 Subject: [PATCH 080/105] refactor(template): one owner per rule, accurate docstrings - --set-var and --variable share commands.shared.parse_assignments. - default_description owns the "Value for " default; the project-filename Jinja placeholder derives from PROJECT_FILENAME_VARIABLE. - Drop the empty template group callback, _or_default, _is_within (now Path.is_relative_to), and the hook-existence check repeated before run_post_create_hook. - Command docstrings state their contract instead of repeating the docs.mdx examples (Typer shows the help= text, not the docstring). - Correct the rendering return contract, the selection module promise, the ALWAYS_IGNORED_PATTERNS comment, and the project-directory docstring. - tests: test_template_commands uses the suite-wide home fixture instead of a second home isolation. Help output for every command is unchanged. --- kicad_lib_manager/commands/shared.py | 14 ++++ .../commands/template/command.py | 10 --- kicad_lib_manager/commands/template/create.py | 49 +++----------- .../commands/template/listing.py | 22 +----- kicad_lib_manager/commands/template/make.py | 67 ++++++------------- kicad_lib_manager/utils/template/authoring.py | 9 +-- kicad_lib_manager/utils/template/constants.py | 3 +- .../utils/template/kicad_files.py | 3 +- kicad_lib_manager/utils/template/models.py | 9 ++- kicad_lib_manager/utils/template/project.py | 2 - kicad_lib_manager/utils/template/rendering.py | 6 +- kicad_lib_manager/utils/template/selection.py | 18 ++--- tests/conftest.py | 4 +- tests/test_template_commands.py | 9 --- 14 files changed, 70 insertions(+), 155 deletions(-) diff --git a/kicad_lib_manager/commands/shared.py b/kicad_lib_manager/commands/shared.py index 7d37b95..ea6eb22 100644 --- a/kicad_lib_manager/commands/shared.py +++ b/kicad_lib_manager/commands/shared.py @@ -24,6 +24,20 @@ def given(value: str | None) -> str | None: 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. diff --git a/kicad_lib_manager/commands/template/command.py b/kicad_lib_manager/commands/template/command.py index 7d1ca41..a6eb286 100644 --- a/kicad_lib_manager/commands/template/command.py +++ b/kicad_lib_manager/commands/template/command.py @@ -6,20 +6,10 @@ from kicad_lib_manager.commands.template.listing import list_templates from kicad_lib_manager.commands.template.make import make - -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. - """ - - 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, ) diff --git a/kicad_lib_manager/commands/template/create.py b/kicad_lib_manager/commands/template/create.py index 36ba326..eba0b6a 100644 --- a/kicad_lib_manager/commands/template/create.py +++ b/kicad_lib_manager/commands/template/create.py @@ -12,6 +12,7 @@ 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 @@ -59,36 +60,12 @@ def create( # noqa: PLR0913 -- Typer maps each CLI option to one parameter bool, typer.Option("--skip-hooks", help="Skip post-creation hooks") ] = False, ) -> None: - r"""Create a new KiCad project from a template. + """Create a KiCad project from a template in one of the configured libraries. - 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 + 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: @@ -202,7 +179,7 @@ def _collect_variables( rendered with the values gathered before it. Returns None when a prompt is cancelled. """ - from_options = _parse_assignments(assignments) + 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: @@ -242,16 +219,6 @@ def _collect_variables( return variables -def _parse_assignments(assignments: list[str]) -> dict[str, str]: - """Parse ``key=value`` options; entries without ``=`` are ignored.""" - parsed: dict[str, str] = {} - for assignment in assignments: - key, separator, value = assignment.partition("=") - if separator: - parsed[key.strip()] = value.strip() - return parsed - - def _target_directory( base: Path, template: DiscoveredTemplate, @@ -305,7 +272,7 @@ def _project_directory( def _prepare_project_directory(project_dir: Path) -> bool: - """Replace an existing project directory, after confirmation, and its parents. + """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. diff --git a/kicad_lib_manager/commands/template/listing.py b/kicad_lib_manager/commands/template/listing.py index a6de148..1f1a554 100644 --- a/kicad_lib_manager/commands/template/listing.py +++ b/kicad_lib_manager/commands/template/listing.py @@ -31,27 +31,9 @@ def list_templates( bool, typer.Option("--json", help="Output in JSON format") ] = False, ) -> None: - r"""List all available templates. + """List the templates of the configured libraries, grouped by library. - 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 + ``--json`` prints the discovered templates as JSON on stdout instead. """ templates = find_all_templates(Config(), library) if not templates: diff --git a/kicad_lib_manager/commands/template/make.py b/kicad_lib_manager/commands/template/make.py index 005ad17..7a251ba 100644 --- a/kicad_lib_manager/commands/template/make.py +++ b/kicad_lib_manager/commands/template/make.py @@ -6,6 +6,7 @@ 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 ( @@ -23,7 +24,10 @@ POST_CREATE_HOOK, PROJECT_FILENAME_VARIABLE, ) -from kicad_lib_manager.utils.template.models import TemplateVariable +from kicad_lib_manager.utils.template.models import ( + TemplateVariable, + default_description, +) from kicad_lib_manager.utils.template.selection import ( TemplatePlan, find_potential_variables, @@ -93,42 +97,13 @@ def make( # noqa: PLR0913 -- Typer maps each CLI option to one parameter bool, typer.Option("--force", help="Overwrite existing template if it exists") ] = False, ) -> None: - r"""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" + """Create a template from an existing KiCad project. - \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 + 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) @@ -286,15 +261,11 @@ def _print_request( def _parse_variables(assignments: list[str]) -> dict[str, TemplateVariable]: - """Parse ``--variable name=value`` options and show them.""" - variables: dict[str, TemplateVariable] = {} - for assignment in assignments: - key, separator, value = assignment.partition("=") - if separator: - key = key.strip() - variables[key] = TemplateVariable( - description=f"Value for {key}", default=value.strip() - ) + """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(): @@ -311,7 +282,9 @@ def _ask_variables(source: Path) -> dict[str, TemplateVariable]: 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=f"Value for {var_name}") + description = typer.prompt( + " Description", default=default_description(var_name) + ) variables[var_name] = TemplateVariable( description=str(description), default=values[0] ) @@ -320,7 +293,7 @@ def _ask_variables(source: Path) -> dict[str, TemplateVariable]: ): var_name = str(typer.prompt("Variable name")) default = str(typer.prompt("Default value", default="")) - description = typer.prompt("Description", default=f"Value for {var_name}") + description = typer.prompt("Description", default=default_description(var_name)) variables[var_name] = TemplateVariable( description=str(description), default=default ) diff --git a/kicad_lib_manager/utils/template/authoring.py b/kicad_lib_manager/utils/template/authoring.py index 28baefa..17ce11a 100644 --- a/kicad_lib_manager/utils/template/authoring.py +++ b/kicad_lib_manager/utils/template/authoring.py @@ -64,8 +64,8 @@ def create_template_metadata( merged[key] = variable metadata = TemplateMetadata( name=name, - description=_or_default(description, f"KiCad project template for {name}"), - use_case=_or_default(use_case, ""), + description=description or f"KiCad project template for {name}", + use_case=use_case or "", version=TEMPLATE_VERSION, variables=merged, ) @@ -171,8 +171,3 @@ def _predefined_variables(name: str) -> dict[str, TemplateVariable]: default="%{project_name}", ), } - - -def _or_default(value: str | None, default: str) -> str: - """Return ``value`` unless it is None or empty.""" - return default if value is None or not value else value diff --git a/kicad_lib_manager/utils/template/constants.py b/kicad_lib_manager/utils/template/constants.py index 113250a..86ced80 100644 --- a/kicad_lib_manager/utils/template/constants.py +++ b/kicad_lib_manager/utils/template/constants.py @@ -40,7 +40,8 @@ # Files larger than this are not scanned for variables. MAX_SCANNED_FILE_BYTES = 1024 * 1024 -# Patterns added to a project's .gitignore when choosing files for a template. +# 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]", diff --git a/kicad_lib_manager/utils/template/kicad_files.py b/kicad_lib_manager/utils/template/kicad_files.py index df7d675..4f96b09 100644 --- a/kicad_lib_manager/utils/template/kicad_files.py +++ b/kicad_lib_manager/utils/template/kicad_files.py @@ -17,11 +17,12 @@ 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 = "{{ project_filename }}" +PROJECT_FILENAME_TEMPLATE = f"{{{{ {PROJECT_FILENAME_VARIABLE} }}}}" SCHEMATIC_PROJECT_PATTERN = re.compile(r'\(project\s+"([^"]+)"') PCB_SHEETFILE_PATTERN = re.compile(r'\(sheetfile\s+"([^"]+)"') diff --git a/kicad_lib_manager/utils/template/models.py b/kicad_lib_manager/utils/template/models.py index 9f9bd65..26c139f 100644 --- a/kicad_lib_manager/utils/template/models.py +++ b/kicad_lib_manager/utils/template/models.py @@ -114,6 +114,11 @@ def write_template_metadata(directory: Path, metadata: TemplateMetadata) -> None 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 _string_keyed(value: object) -> dict[str, object] | None: """Return a YAML mapping with its keys as text, or None for other values.""" if not isinstance(value, Mapping): @@ -135,7 +140,9 @@ def _variables(raw: dict[str, object]) -> dict[str, TemplateVariable]: description = _text(fields.get("description")) default = _text(fields.get("default")) variables[name] = TemplateVariable( - description=f"Value for {name}" if description is None else description, + description=default_description(name) + if description is None + else description, default="" if default is None else default, ) return variables diff --git a/kicad_lib_manager/utils/template/project.py b/kicad_lib_manager/utils/template/project.py index 8ec3d46..d92f7e6 100644 --- a/kicad_lib_manager/utils/template/project.py +++ b/kicad_lib_manager/utils/template/project.py @@ -139,8 +139,6 @@ def _run_hook( template: DiscoveredTemplate, ) -> None: """Run the template's post-creation hook, reporting any failure.""" - if not hook_script.exists(): - return 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 diff --git a/kicad_lib_manager/utils/template/rendering.py b/kicad_lib_manager/utils/template/rendering.py index d1e09bf..85b8c64 100644 --- a/kicad_lib_manager/utils/template/rendering.py +++ b/kicad_lib_manager/utils/template/rendering.py @@ -96,8 +96,10 @@ def render_template_file( fails, the source is copied unchanged. Returns: - False when the file was not rendered as intended or could not be - written at all, True otherwise. + 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: diff --git a/kicad_lib_manager/utils/template/selection.py b/kicad_lib_manager/utils/template/selection.py index 6d01458..f73b084 100644 --- a/kicad_lib_manager/utils/template/selection.py +++ b/kicad_lib_manager/utils/template/selection.py @@ -1,7 +1,8 @@ """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, the copy, and the variable scan agree. +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. @@ -198,8 +199,10 @@ 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 _is_within( - self.source / relative, self.template_root + 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) @@ -222,15 +225,6 @@ def file_exclusion(self, relative: PurePath) -> Exclusion | None: return None -def _is_within(path: Path, directory: Path) -> bool: - """Whether ``path`` is ``directory`` or inside it, after resolving links.""" - try: - path.resolve().relative_to(directory.resolve()) - except ValueError: - return False - return True - - 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] = {} diff --git a/tests/conftest.py b/tests/conftest.py index 6a8e5e2..2ca21b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,10 +13,10 @@ def _no_network(*_args: object, **_kwargs: object) -> requests.Response: @pytest.fixture(autouse=True) -def _isolated_user( +def home( tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch ) -> Path: - """Give each test an empty home and no network. + """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 diff --git a/tests/test_template_commands.py b/tests/test_template_commands.py index ae9eee5..75bc466 100644 --- a/tests/test_template_commands.py +++ b/tests/test_template_commands.py @@ -17,15 +17,6 @@ ) -@pytest.fixture -def home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """An empty home directory, so Config reads and writes only test data.""" - home_dir = tmp_path / "home" - home_dir.mkdir() - monkeypatch.setattr(Path, "home", lambda: home_dir) - return home_dir - - def add_library(home: Path, name: str) -> Path: """Configure a GitHub library called ``name`` and return its directory.""" library = home / name From d2881ed688071e238ea7489159981237e93d38cd Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:38:18 +0200 Subject: [PATCH 081/105] docs(template): match the make and create arguments and outputs to the code --- kicad_lib_manager/commands/template/docs.mdx | 28 ++++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/kicad_lib_manager/commands/template/docs.mdx b/kicad_lib_manager/commands/template/docs.mdx index a73ecfe..bbc1798 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 @@ -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. From 29311c0ede58fdb3e5fcd9e32a2d5474afb9ad21 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:40:18 +0200 Subject: [PATCH 082/105] refactor: one owner for kicad_common.json access commands/kicad_common.py moves to utils/kicad_common.py so utils/env_vars can use it: JsonObject, the pinned-list keys, and the backed-up atomic replace (replace_json_object) now live in one module instead of being repeated in env_vars and unpin. env_vars keeps its strict loader, which reports KiCadConfigError. tests: the failed-write test for kicad_common.json now fails the write itself (fault_injection.fail_writes) instead of JSON encoding, which happens before any write, so it catches a non-atomic writer. A pinning test covers keeping existing pins, both library kinds, repeats, and dry runs. --- kicad_lib_manager/commands/init/command.py | 2 +- kicad_lib_manager/commands/status/command.py | 14 +++---- kicad_lib_manager/commands/unpin/command.py | 14 +++---- kicad_lib_manager/utils/env_vars.py | 29 ++++++--------- .../{commands => utils}/kicad_common.py | 25 ++++++++----- tests/test_env_vars.py | 37 ++++++++++++------- tests/test_unpin_command.py | 7 +++- 7 files changed, 71 insertions(+), 57 deletions(-) rename kicad_lib_manager/{commands => utils}/kicad_common.py (65%) diff --git a/kicad_lib_manager/commands/init/command.py b/kicad_lib_manager/commands/init/command.py index feeb216..878caa8 100644 --- a/kicad_lib_manager/commands/init/command.py +++ b/kicad_lib_manager/commands/init/command.py @@ -12,10 +12,10 @@ from rich.panel import Panel from rich.prompt import Confirm -from kicad_lib_manager.commands.kicad_common import as_json_object 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.kicad_common import as_json_object LIBRARY_DESCRIPTIONS_FILE = "library_descriptions.yaml" LIBRARY_DESCRIPTIONS_TEMPLATE = """\ diff --git a/kicad_lib_manager/commands/status/command.py b/kicad_lib_manager/commands/status/command.py index 0447d3a..6af58be 100644 --- a/kicad_lib_manager/commands/status/command.py +++ b/kicad_lib_manager/commands/status/command.py @@ -17,21 +17,21 @@ from rich.table import Table from rich.text import Text -from kicad_lib_manager.commands.kicad_common import ( +from kicad_lib_manager.services.kicad_service import KiCadService +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 +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.services.kicad_service import KiCadService -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.env_vars import JsonObject -from kicad_lib_manager.utils.file_ops import LibraryTableEntry from kicad_lib_manager.utils.metadata import read_cloud_metadata, read_github_metadata LEGACY_PINNED_FILE = "pinned" diff --git a/kicad_lib_manager/commands/unpin/command.py b/kicad_lib_manager/commands/unpin/command.py index 4c0abc2..dbc99d0 100644 --- a/kicad_lib_manager/commands/unpin/command.py +++ b/kicad_lib_manager/commands/unpin/command.py @@ -6,19 +6,18 @@ import typer from rich.panel import Panel -from kicad_lib_manager.commands.kicad_common import ( +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 KICAD_COMMON_FILE +from kicad_lib_manager.utils.kicad_common import ( PINNED_FOOTPRINTS_KEY, PINNED_SYMBOLS_KEY, as_list, load_json_object, pinned_names, - save_json_object, + replace_json_object, session_of, ) -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 KICAD_COMMON_FILE NO_SESSION = ( "[yellow]No session information found in KiCad configuration, " @@ -97,8 +96,7 @@ def _remove_pins( session[key] = kept if changed and not dry_run: config["session"] = session - create_backup(kicad_common, max_backups) - save_json_object(kicad_common, config) + replace_json_object(kicad_common, config, max_backups=max_backups) return changed diff --git a/kicad_lib_manager/utils/env_vars.py b/kicad_lib_manager/utils/env_vars.py index 6618f3f..2bb4082 100644 --- a/kicad_lib_manager/utils/env_vars.py +++ b/kicad_lib_manager/utils/env_vars.py @@ -16,16 +16,18 @@ from typing import cast from kicad_lib_manager.services.config_service import Config -from kicad_lib_manager.utils.backup import create_backup from kicad_lib_manager.utils.constants import ( CLOUD_METADATA_FILE, DEFAULT_MAX_BACKUPS, GITHUB_METADATA_FILE, KICAD_COMMON_FILE, ) -from kicad_lib_manager.utils.file_ops import write_atomically - -JsonObject = dict[str, object] +from kicad_lib_manager.utils.kicad_common import ( + PINNED_FOOTPRINTS_KEY, + PINNED_SYMBOLS_KEY, + JsonObject, + replace_json_object, +) class KiCadConfigError(ValueError): @@ -176,7 +178,7 @@ def update_kicad_env_vars( return False if not dry_run: environment["vars"] = updated - _save_kicad_common(kicad_common, config, max_backups) + replace_json_object(kicad_common, config, max_backups=max_backups) return True @@ -208,8 +210,8 @@ def update_pinned_libraries( KiCadConfigError: kicad_common.json is not valid JSON of the expected shape. """ requested = { - "pinned_symbol_libs": () if symbol_libs is None else symbol_libs, - "pinned_fp_libs": () if footprint_libs is None else footprint_libs, + 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 @@ -217,8 +219,8 @@ def update_pinned_libraries( 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, } session = _object_entry(config, "session", kicad_common) @@ -231,7 +233,7 @@ def update_pinned_libraries( pinned.extend(new) if changed and not dry_run: - _save_kicad_common(kicad_common, config, max_backups) + replace_json_object(kicad_common, config, max_backups=max_backups) return changed @@ -252,13 +254,6 @@ def _load_kicad_common(kicad_config: Path) -> tuple[Path, JsonObject]: return kicad_common, cast("JsonObject", document) -def _save_kicad_common( - kicad_common: Path, config: JsonObject, max_backups: int -) -> None: - create_backup(kicad_common, max_backups) - write_atomically(kicad_common, json.dumps(config, indent=2).encode("utf-8")) - - 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) diff --git a/kicad_lib_manager/commands/kicad_common.py b/kicad_lib_manager/utils/kicad_common.py similarity index 65% rename from kicad_lib_manager/commands/kicad_common.py rename to kicad_lib_manager/utils/kicad_common.py index 964b9dc..019f30f 100644 --- a/kicad_lib_manager/commands/kicad_common.py +++ b/kicad_lib_manager/utils/kicad_common.py @@ -1,31 +1,34 @@ -"""Typed access to KiCad's ``kicad_common.json``. +"""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 read the JSON into plain objects, narrow the parts the -commands use, and write the file back atomically, so a failed write never -leaves KiCad with a truncated configuration. +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.env_vars import JsonObject +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 ``value`` as a JSON object, or None when it is not one.""" + """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 ``value`` when it is a JSON array, otherwise an empty list.""" + """Return a copy of ``value`` when it is a list, otherwise an empty list.""" if not isinstance(value, list): return [] return [*value] @@ -55,6 +58,10 @@ def load_json_object(path: Path) -> JsonObject: return data -def save_json_object(path: Path, data: JsonObject) -> None: - """Replace ``path`` atomically with ``data`` as indented JSON.""" +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/tests/test_env_vars.py b/tests/test_env_vars.py index c60782b..ec62064 100644 --- a/tests/test_env_vars.py +++ b/tests/test_env_vars.py @@ -1,7 +1,7 @@ """Tests for KiCad environment variables and pinned libraries.""" import json -from collections.abc import Callable, Iterator +from collections.abc import Callable from pathlib import Path import pytest @@ -12,6 +12,7 @@ update_kicad_env_vars, update_pinned_libraries, ) +from tests.fault_injection import fail_writes def test_update_kicad_env_vars(tmp_path: Path) -> None: @@ -107,16 +108,6 @@ def test_update_kicad_env_vars(tmp_path: Path) -> None: update_kicad_env_vars(kicad_config, env_vars) -class _DiskFullError(OSError): - """Stands in for a disk error in the middle of writing JSON.""" - - -def _failing_iterencode(*_args: object, **_kwargs: object) -> Iterator[str]: - """Yield the start of a JSON document, then fail like a full disk.""" - yield '{"environment": ' - raise _DiskFullError - - @pytest.mark.parametrize( "update", [ @@ -134,14 +125,34 @@ def test_failed_write_keeps_kicad_common( kicad_common = tmp_path / "kicad_common.json" original = json.dumps({"environment": {"vars": {}}, "session": {}}) kicad_common.write_text(original, encoding="utf-8") - monkeypatch.setattr(json.JSONEncoder, "iterencode", _failing_iterencode) + fail_writes(monkeypatch) - with pytest.raises(_DiskFullError): + 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.""" diff --git a/tests/test_unpin_command.py b/tests/test_unpin_command.py index b72d45a..32ee895 100644 --- a/tests/test_unpin_command.py +++ b/tests/test_unpin_command.py @@ -6,10 +6,13 @@ import pytest from typer.testing import CliRunner -from kicad_lib_manager.commands.kicad_common import load_json_object, session_of from kicad_lib_manager.main import app from kicad_lib_manager.services.library_service import LibraryService -from kicad_lib_manager.utils.env_vars import JsonObject +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'" From 39abc06be86fe048f2bff0a97c7e9e1495496fe6 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:41:56 +0200 Subject: [PATCH 083/105] refactor: keep KiCad names and model suffixes in utils.constants - SYMBOL_DIR_VAR, MODELS_DIR_VAR, SYMBOL_TABLE_FILE, FOOTPRINT_TABLE_FILE, and MODEL_EXTENSIONS are defined once in utils/constants.py instead of in commands/shared, setup/changes, add_3d, config, metadata, and as literals in Typer envvar options, file_ops, and kicad_config. - Typer --max-backups defaults use DEFAULT_MAX_BACKUPS. - metadata.count_model_files replaces the identical counter in add-3d. - env_vars pairs each library variable's configured path and marker file in one registry, so the two lookups cannot disagree. - setup's fix_invalid_uris loses backup_first, which only tests set; the tests now take the backup like setup does and use the suite-wide home. Values, lookup order, and help output are unchanged. --- kicad_lib_manager/commands/add_3d/command.py | 12 +----- kicad_lib_manager/commands/config/command.py | 9 ++-- .../commands/list_libraries/command.py | 3 +- kicad_lib_manager/commands/pin/command.py | 5 ++- kicad_lib_manager/commands/setup/changes.py | 28 ++++++++----- kicad_lib_manager/commands/setup/command.py | 11 +++-- kicad_lib_manager/commands/setup/sources.py | 3 +- kicad_lib_manager/commands/shared.py | 11 ++--- kicad_lib_manager/commands/unpin/command.py | 4 +- kicad_lib_manager/services/kicad_config.py | 6 ++- kicad_lib_manager/services/library_table.py | 5 ++- kicad_lib_manager/utils/constants.py | 12 +++++- kicad_lib_manager/utils/env_vars.py | 41 +++++++++++-------- kicad_lib_manager/utils/file_ops.py | 8 ++-- kicad_lib_manager/utils/metadata.py | 18 +++++--- tests/test_setup_command.py | 8 +--- 16 files changed, 107 insertions(+), 77 deletions(-) diff --git a/kicad_lib_manager/commands/add_3d/command.py b/kicad_lib_manager/commands/add_3d/command.py index 6a7ff2b..fff4276 100644 --- a/kicad_lib_manager/commands/add_3d/command.py +++ b/kicad_lib_manager/commands/add_3d/command.py @@ -16,14 +16,13 @@ from kicad_lib_manager.utils.metadata import ( CLOUD_METADATA_FILE, CloudMetadata, + count_model_files, generate_env_var_name, get_default_cloud_metadata, read_cloud_metadata, write_cloud_metadata, ) -MODEL_EXTENSIONS = (".step", ".stp", ".wrl", ".wings") - @dataclass(frozen=True) class _Overrides: @@ -129,13 +128,6 @@ def _create_new( return metadata -def _count_models(directory: Path) -> int: - """Return the number of 3D model files below ``directory``.""" - return sum( - len(list(directory.glob(f"**/*{extension}"))) for extension in MODEL_EXTENSIONS - ) - - def _confirm_models(model_count: int) -> None: """Ask before registering a directory without models; exit on refusal.""" if model_count > 0: @@ -250,7 +242,7 @@ def add_3d( # noqa: PLR0913 -- each CLI option is a Typer parameter else: metadata = _create_new(directory, overrides, overwriting=has_existing) - model_count = _count_models(directory) + model_count = count_model_files(directory) _confirm_models(model_count) metadata["model_count"] = model_count write_cloud_metadata(directory, metadata) diff --git a/kicad_lib_manager/commands/config/command.py b/kicad_lib_manager/commands/config/command.py index c899115..90379d5 100644 --- a/kicad_lib_manager/commands/config/command.py +++ b/kicad_lib_manager/commands/config/command.py @@ -13,12 +13,13 @@ 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.constants import ( + CLOUD_METADATA_FILE, + GITHUB_METADATA_FILE, + MODEL_EXTENSIONS, +) from kicad_lib_manager.utils.metadata import read_cloud_metadata, read_github_metadata -MODEL_EXTENSIONS: Final = (".step", ".stp", ".wrl", ".wings") -"""File extensions counted as 3D models when a library does not state its count.""" - @dataclass(frozen=True) class LibraryKind: diff --git a/kicad_lib_manager/commands/list_libraries/command.py b/kicad_lib_manager/commands/list_libraries/command.py index 49bd3d4..8b8a2d9 100644 --- a/kicad_lib_manager/commands/list_libraries/command.py +++ b/kicad_lib_manager/commands/list_libraries/command.py @@ -9,6 +9,7 @@ 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 def _print_library_table( @@ -40,7 +41,7 @@ def list_cmd( help=( "KiCad library directory (uses KICAD_USER_LIB env var if not specified)" ), - envvar="KICAD_USER_LIB", + envvar=SYMBOL_DIR_VAR, ), ] = None, ) -> None: diff --git a/kicad_lib_manager/commands/pin/command.py b/kicad_lib_manager/commands/pin/command.py index 1851576..11f9ab1 100644 --- a/kicad_lib_manager/commands/pin/command.py +++ b/kicad_lib_manager/commands/pin/command.py @@ -11,6 +11,7 @@ from kicad_lib_manager.services.kicad_service import KiCadService 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 @@ -111,7 +112,7 @@ def pin( # noqa: PLR0913 -- each CLI option is a Typer parameter str | None, typer.Option( "--kicad-lib-dir", - envvar="KICAD_USER_LIB", + envvar=SYMBOL_DIR_VAR, help=( "KiCad library directory (uses KICAD_USER_LIB env var if not specified)" ), @@ -153,7 +154,7 @@ def pin( # noqa: PLR0913 -- each CLI option is a Typer parameter "--max-backups", help="Maximum number of backups to keep", ), - ] = 5, + ] = DEFAULT_MAX_BACKUPS, verbose: Annotated[ bool, typer.Option( diff --git a/kicad_lib_manager/commands/setup/changes.py b/kicad_lib_manager/commands/setup/changes.py index 1e97ed3..b12043f 100644 --- a/kicad_lib_manager/commands/setup/changes.py +++ b/kicad_lib_manager/commands/setup/changes.py @@ -4,19 +4,22 @@ from dataclasses import dataclass from pathlib import Path -from kicad_lib_manager.commands.shared import MODELS_DIR_VAR, SYMBOL_DIR_VAR 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 -SYMBOL_TABLE = "sym-lib-table" -FOOTPRINT_TABLE = "fp-lib-table" - # A path wrapped as if it were a variable: ``(uri "${/path/lib}/Lib.kicad_sym")``. _WRAPPED_URI = re.compile(r'\(uri "\${(\/[^}]+)}\/(.*?)"\)') @@ -68,15 +71,15 @@ def model_dirs(self) -> dict[str, str]: def fix_invalid_uris( kicad_config: Path, *, - backup_first: bool = True, - max_backups: int = 5, + 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. - backup_first: Back up each table before changing it. max_backups: The number of backups to keep per table. dry_run: Only report whether a table needs fixing. @@ -87,7 +90,8 @@ def fix_invalid_uris( OSError: A table cannot be read, backed up, or written. """ changes_made = False - for table_path in (kicad_config / SYMBOL_TABLE, kicad_config / FOOTPRINT_TABLE): + 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") @@ -96,8 +100,7 @@ def fix_invalid_uris( changes_made = True if dry_run: continue - if backup_first: - create_backup(table_path, max_backups) + 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 @@ -154,7 +157,10 @@ def add_libraries(options: ChangeOptions, libraries: SetupLibraries) -> bool: # 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, "symbol"), (FOOTPRINT_TABLE, "footprint")): + 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) diff --git a/kicad_lib_manager/commands/setup/command.py b/kicad_lib_manager/commands/setup/command.py index f000068..f6d56a1 100644 --- a/kicad_lib_manager/commands/setup/command.py +++ b/kicad_lib_manager/commands/setup/command.py @@ -20,6 +20,11 @@ ) 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 @@ -29,7 +34,7 @@ def setup( # noqa: PLR0913 -- each CLI option is a Typer parameter str | None, typer.Option( "--kicad-lib-dir", - envvar="KICAD_USER_LIB", + envvar=SYMBOL_DIR_VAR, help="KiCad library directory (uses KICAD_USER_LIB env var if not " "specified)", ), @@ -38,7 +43,7 @@ def setup( # noqa: PLR0913 -- each CLI option is a Typer parameter str | None, typer.Option( "--kicad-3d-dir", - envvar="KICAD_3D_LIB", + envvar=MODELS_DIR_VAR, help="KiCad 3D models directory (uses KICAD_3D_LIB env var if not " "specified)", ), @@ -68,7 +73,7 @@ def setup( # noqa: PLR0913 -- each CLI option is a Typer parameter ] = False, max_backups: Annotated[ int, typer.Option("--max-backups", help="Maximum number of backups to keep") - ] = 5, + ] = DEFAULT_MAX_BACKUPS, dry_run: Annotated[ bool, typer.Option( diff --git a/kicad_lib_manager/commands/setup/sources.py b/kicad_lib_manager/commands/setup/sources.py index f73cf38..4536171 100644 --- a/kicad_lib_manager/commands/setup/sources.py +++ b/kicad_lib_manager/commands/setup/sources.py @@ -12,9 +12,10 @@ import typer from rich.table import Table -from kicad_lib_manager.commands.shared import MODELS_DIR_VAR, SYMBOL_DIR_VAR, given +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_environment_variables from kicad_lib_manager.utils.metadata import read_cloud_metadata, read_github_metadata diff --git a/kicad_lib_manager/commands/shared.py b/kicad_lib_manager/commands/shared.py index ea6eb22..dba514f 100644 --- a/kicad_lib_manager/commands/shared.py +++ b/kicad_lib_manager/commands/shared.py @@ -3,17 +3,12 @@ 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_environment_variables, ) -SYMBOL_DIR_VAR = "KICAD_USER_LIB" -"""The variable naming the main symbol and footprint library directory.""" - -MODELS_DIR_VAR = "KICAD_3D_LIB" -"""The variable naming the main 3D model library directory.""" - def given(value: str | None) -> str | None: """Return ``value``, or None when an option was omitted or set to ``""``. @@ -42,8 +37,8 @@ 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_environment_variables`` searches. Prints an error and exits with - status 1 when neither names a directory. + ``find_environment_variables`` 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_environment_variables(SYMBOL_DIR_VAR)) if directory is None: diff --git a/kicad_lib_manager/commands/unpin/command.py b/kicad_lib_manager/commands/unpin/command.py index dbc99d0..db06dc6 100644 --- a/kicad_lib_manager/commands/unpin/command.py +++ b/kicad_lib_manager/commands/unpin/command.py @@ -8,7 +8,7 @@ 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 KICAD_COMMON_FILE +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, @@ -163,7 +163,7 @@ def unpin( # noqa: PLR0913 -- each CLI option is a Typer parameter ] = False, max_backups: Annotated[ int, typer.Option("--max-backups", help="Maximum number of backups to keep") - ] = 5, + ] = DEFAULT_MAX_BACKUPS, verbose: Annotated[ bool, typer.Option("--verbose", "-v", help="Show verbose output for debugging"), diff --git a/kicad_lib_manager/services/kicad_config.py b/kicad_lib_manager/services/kicad_config.py index b01ebf8..acf3cb0 100644 --- a/kicad_lib_manager/services/kicad_config.py +++ b/kicad_lib_manager/services/kicad_config.py @@ -7,6 +7,8 @@ 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." @@ -72,8 +74,8 @@ def find_kicad_config( latest = max(versions, key=lambda entry: (_version_key(entry), entry.name)) if ( - not (latest / "sym-lib-table").exists() - and not (latest / "fp-lib-table").exists() + 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) diff --git a/kicad_lib_manager/services/library_table.py b/kicad_lib_manager/services/library_table.py index fc3b003..e0e7dbc 100644 --- a/kicad_lib_manager/services/library_table.py +++ b/kicad_lib_manager/services/library_table.py @@ -12,6 +12,7 @@ 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, @@ -24,8 +25,8 @@ """The library subdirectory of a KiLM library, and so the KiCad table it goes in.""" TABLE_FILES: Final[dict[LibraryKind, str]] = { - "symbols": "sym-lib-table", - "footprints": "fp-lib-table", + "symbols": SYMBOL_TABLE_FILE, + "footprints": FOOTPRINT_TABLE_FILE, } LIBRARY_SUFFIXES: Final[dict[LibraryKind, str]] = { diff --git a/kicad_lib_manager/utils/constants.py b/kicad_lib_manager/utils/constants.py index 20121a1..3f831c1 100644 --- a/kicad_lib_manager/utils/constants.py +++ b/kicad_lib_manager/utils/constants.py @@ -4,8 +4,18 @@ GITHUB_METADATA_FILE = "kilm.yaml" CLOUD_METADATA_FILE = ".kilm_metadata" -# KiCad's own configuration file in its configuration directory +# 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" diff --git a/kicad_lib_manager/utils/env_vars.py b/kicad_lib_manager/utils/env_vars.py index 2bb4082..8b42334 100644 --- a/kicad_lib_manager/utils/env_vars.py +++ b/kicad_lib_manager/utils/env_vars.py @@ -13,7 +13,7 @@ import subprocess from collections.abc import Callable, Mapping, Sequence from pathlib import Path -from typing import cast +from typing import NamedTuple, cast from kicad_lib_manager.services.config_service import Config from kicad_lib_manager.utils.constants import ( @@ -21,6 +21,8 @@ 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, @@ -34,15 +36,22 @@ class KiCadConfigError(ValueError): """kicad_common.json is not valid JSON or not shaped as KiCad writes it.""" -# The library variables KiLM can derive from its configuration and from the -# metadata file of a library in the current directory. -_CONFIG_PATHS: dict[str, Callable[[Config], str | None]] = { - "KICAD_USER_LIB": lambda config: config.get_symbol_library_path(), - "KICAD_3D_LIB": lambda config: config.get_3d_library_path(), -} -_LIBRARY_MARKERS = { - "KICAD_USER_LIB": GITHUB_METADATA_FILE, - "KICAD_3D_LIB": CLOUD_METADATA_FILE, +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") @@ -74,23 +83,23 @@ def find_environment_variables(var_name: str) -> str | None: def _from_config(var_name: str) -> str | None: - read_path = _CONFIG_PATHS.get(var_name) - if read_path is None: + variable = _LIBRARY_VARIABLES.get(var_name) + if variable is None: return None try: - path = read_path(Config()) + path = variable.configured_path(Config()) except (OSError, LookupError, TypeError, ValueError): return None return path or None def _from_current_directory(var_name: str) -> str | None: - marker = _LIBRARY_MARKERS.get(var_name) - if marker is None: + variable = _LIBRARY_VARIABLES.get(var_name) + if variable is None: return None try: current_dir = Path.cwd().resolve() - is_library = (current_dir / marker).exists() + is_library = (current_dir / variable.marker_file).exists() except OSError: return None return str(current_dir) if is_library else None diff --git a/kicad_lib_manager/utils/file_ops.py b/kicad_lib_manager/utils/file_ops.py index 6802669..6e8db8d 100644 --- a/kicad_lib_manager/utils/file_ops.py +++ b/kicad_lib_manager/utils/file_ops.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import Literal, 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. @@ -120,7 +122,7 @@ def validate_lib_table( ValueError: The table is missing and create_if_missing is False, or its content is not a table of its kind. """ - table_type = "fp" if table_path.name == "fp-lib-table" else "sym" + table_type = "fp" if table_path.name == FOOTPRINT_TABLE_FILE else "sym" if not table_path.exists(): if not create_if_missing: msg = f"Library table not found: {table_path}" @@ -174,8 +176,8 @@ def list_configured_libraries( (symbol library entries, footprint library entries) """ return ( - _read_lib_table(kicad_config / "sym-lib-table"), - _read_lib_table(kicad_config / "fp-lib-table"), + _read_lib_table(kicad_config / SYMBOL_TABLE_FILE), + _read_lib_table(kicad_config / FOOTPRINT_TABLE_FILE), ) diff --git a/kicad_lib_manager/utils/metadata.py b/kicad_lib_manager/utils/metadata.py index 0dac759..08ae14f 100644 --- a/kicad_lib_manager/utils/metadata.py +++ b/kicad_lib_manager/utils/metadata.py @@ -24,6 +24,7 @@ CLOUD_METADATA_FILE, GITHUB_METADATA_FILE, MAX_ENV_VAR_NAME_LENGTH, + MODEL_EXTENSIONS, ) __all__ = [ @@ -32,6 +33,7 @@ "CloudMetadata", "GitHubMetadata", "LibraryCapabilities", + "count_model_files", "generate_env_var_name", "get_default_cloud_metadata", "get_default_github_metadata", @@ -41,8 +43,6 @@ "write_github_metadata", ] -_MODEL_SUFFIXES = (".step", ".stp", ".wrl", ".wings") - class LibraryCapabilities(TypedDict, total=False): """Which kinds of content a library has, by its top-level directories.""" @@ -252,6 +252,16 @@ def get_default_github_metadata(directory: Path) -> GitHubMetadata: } +def count_model_files(directory: Path) -> int: + """Return the number of entries below ``directory`` named like 3D models. + + Matches MODEL_EXTENSIONS case-sensitively at any depth. + """ + return sum( + len(list(directory.glob(f"**/*{extension}"))) for extension in MODEL_EXTENSIONS + ) + + def get_default_cloud_metadata(directory: Path) -> CloudMetadata: """Metadata for a new 3D model library in directory, named after the directory. @@ -259,9 +269,7 @@ def get_default_cloud_metadata(directory: Path) -> CloudMetadata: KICAD_3D_. """ name = directory.name - model_count = sum( - len(list(directory.glob(f"**/*{suffix}"))) for suffix in _MODEL_SUFFIXES - ) + model_count = count_model_files(directory) return { "name": name, "description": f"KiCad 3D model library {name}", diff --git a/tests/test_setup_command.py b/tests/test_setup_command.py index eba9715..aa74383 100644 --- a/tests/test_setup_command.py +++ b/tests/test_setup_command.py @@ -25,10 +25,6 @@ @pytest.fixture def kicad_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Return an isolated KiCad configuration directory that setup finds.""" - home = tmp_path / "home" - home.mkdir() - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("USERPROFILE", str(home)) monkeypatch.setattr(setup_sources, "find_environment_variables", lambda _name: None) config = tmp_path / "kicad" config.mkdir() @@ -72,7 +68,7 @@ def test_fix_invalid_uris_rewrites_wrapped_paths(kicad_config: Path) -> None: table = kicad_config / "sym-lib-table" table.write_text(INVALID_URI_TABLE, encoding="utf-8") - assert setup_changes.fix_invalid_uris(kicad_config, backup_first=False) + assert setup_changes.fix_invalid_uris(kicad_config) assert '(uri "/libs/symbols/Bad.kicad_sym")' in table.read_text(encoding="utf-8") @@ -86,7 +82,7 @@ def test_fix_invalid_uris_keeps_the_table_when_writing_fails( fail_writes(monkeypatch) with pytest.raises(OSError, match="No space left"): - setup_changes.fix_invalid_uris(kicad_config, backup_first=False) + setup_changes.fix_invalid_uris(kicad_config) assert table.read_text(encoding="utf-8") == INVALID_URI_TABLE From ff5d61741446af2f49fd5bb3ab5dde85f7510371 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:41:57 +0200 Subject: [PATCH 084/105] test: use the suite-wide home fixture instead of per-file HOME setup --- tests/test_env_vars.py | 8 +++----- tests/test_init_command.py | 6 +----- tests/test_sync_command.py | 8 ++------ 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/tests/test_env_vars.py b/tests/test_env_vars.py index ec62064..626f1a6 100644 --- a/tests/test_env_vars.py +++ b/tests/test_env_vars.py @@ -164,12 +164,10 @@ def get_symbol_library_path(self) -> str | None: @pytest.fixture -def isolated_sources(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: +def isolated_sources( + home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Path: """No source defines KICAD_USER_LIB; returns the empty home directory.""" - home = tmp_path / "home" - home.mkdir() - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("USERPROFILE", str(home)) monkeypatch.delenv("KICAD_USER_LIB", raising=False) monkeypatch.setattr(env_vars, "Config", _FakeConfig) monkeypatch.setattr(env_vars.shutil, "which", lambda _name: None) diff --git a/tests/test_init_command.py b/tests/test_init_command.py index 7d25c44..f7b948a 100644 --- a/tests/test_init_command.py +++ b/tests/test_init_command.py @@ -10,11 +10,7 @@ @pytest.fixture def library(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Return an empty library directory, made current, with a private home.""" - home = tmp_path / "home" - home.mkdir() - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("USERPROFILE", str(home)) + """Return an empty library directory, made current.""" directory = tmp_path / "library" directory.mkdir() monkeypatch.chdir(directory) diff --git a/tests/test_sync_command.py b/tests/test_sync_command.py index edce427..ddb8f60 100644 --- a/tests/test_sync_command.py +++ b/tests/test_sync_command.py @@ -47,12 +47,8 @@ def get_libraries(self, library_type: str | None = None) -> list[LibraryDict]: @pytest.fixture(autouse=True) -def isolated_git(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Keep git and KiLM away from the user's configuration.""" - home = tmp_path / "home" - home.mkdir() - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("USERPROFILE", str(home)) +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") From e11d52c97521250d81c1c7ef68236062ebf23f62 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:44:05 +0200 Subject: [PATCH 085/105] refactor: drop KiCadService forwarder; pin and status call LibraryService and file_ops directly --- kicad_lib_manager/commands/pin/command.py | 3 +- kicad_lib_manager/commands/status/command.py | 16 ++++++---- kicad_lib_manager/services/kicad_service.py | 32 ------------------- kicad_lib_manager/services/library_service.py | 2 +- 4 files changed, 11 insertions(+), 42 deletions(-) delete mode 100644 kicad_lib_manager/services/kicad_service.py diff --git a/kicad_lib_manager/commands/pin/command.py b/kicad_lib_manager/commands/pin/command.py index 11f9ab1..4b97450 100644 --- a/kicad_lib_manager/commands/pin/command.py +++ b/kicad_lib_manager/commands/pin/command.py @@ -8,7 +8,6 @@ from rich.table import Table from kicad_lib_manager.commands.shared import resolve_kicad_lib_dir -from kicad_lib_manager.services.kicad_service import KiCadService 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 @@ -19,7 +18,7 @@ def _find_kicad_config(*, verbose: bool) -> Path: """Return KiCad's configuration directory; exit when it cannot be found.""" console = get_console() try: - kicad_config = KiCadService().find_kicad_config_dir() + 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 diff --git a/kicad_lib_manager/commands/status/command.py b/kicad_lib_manager/commands/status/command.py index 6af58be..3922cc4 100644 --- a/kicad_lib_manager/commands/status/command.py +++ b/kicad_lib_manager/commands/status/command.py @@ -17,11 +17,14 @@ from rich.table import Table from rich.text import Text -from kicad_lib_manager.services.kicad_service import KiCadService +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 +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, @@ -54,15 +57,14 @@ def status( """Show the current KiCad configuration status.""" console = get_console() try: - kicad_service = KiCadService() _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]") _show_kicad_environment_variables(kicad_config) _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 @@ -270,10 +272,10 @@ def _print_configured(kind: str, libraries: list[LibraryTableEntry]) -> None: console.print(table) -def _show_configured_libraries(kicad_config: Path, kicad_service: KiCadService) -> None: +def _show_configured_libraries(kicad_config: Path) -> None: """Show the libraries in KiCad's symbol and footprint library tables.""" try: - symbols, footprints = kicad_service.get_configured_libraries(kicad_config) + symbols, footprints = list_configured_libraries(kicad_config) except (OSError, ValueError) as e: get_console().print(f"[red]Error listing configured libraries: {e}[/red]") return diff --git a/kicad_lib_manager/services/kicad_service.py b/kicad_lib_manager/services/kicad_service.py deleted file mode 100644 index ae68a5f..0000000 --- a/kicad_lib_manager/services/kicad_service.py +++ /dev/null @@ -1,32 +0,0 @@ -"""KiCad configuration lookups used by the pin and status commands.""" - -from pathlib import Path - -from kicad_lib_manager.services.library_service import LibraryService -from kicad_lib_manager.utils.file_ops import ( - LibraryTableEntry, - list_configured_libraries, -) - - -class KiCadService: - """Finds KiCad's configuration and the libraries its tables list.""" - - @staticmethod - def find_kicad_config_dir() -> Path: - """Return the newest KiCad version's configuration directory. - - Goes through `LibraryService.find_kicad_config` so that one patch - point covers every command. - - Raises: - FileNotFoundError: KiCad's configuration cannot be found. - """ - return LibraryService.find_kicad_config() - - @staticmethod - def get_configured_libraries( - config_dir: Path, - ) -> tuple[list[LibraryTableEntry], list[LibraryTableEntry]]: - """Return the entries of KiCad's symbol and footprint library tables.""" - return list_configured_libraries(config_dir) diff --git a/kicad_lib_manager/services/library_service.py b/kicad_lib_manager/services/library_service.py index d5d7f5b..e537873 100644 --- a/kicad_lib_manager/services/library_service.py +++ b/kicad_lib_manager/services/library_service.py @@ -20,7 +20,7 @@ class LibraryService: - """Library operations used by the init, list, pin, setup, and unpin commands. + """Library operations behind the init, list, pin, setup, status, and unpin commands. `add_libraries` and `find_kicad_config` are the module functions of library_table and kicad_config. They stay class attributes because From 9a674b1d43d1bc6f2937570a92fbb77a34241e1d Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:45:23 +0200 Subject: [PATCH 086/105] refactor: metadata owns the library folder schema and model counting LIBRARY_FOLDERS and library_capabilities live in utils/metadata and back get_default_github_metadata, initialize_library, config's feature labels, and init's folder report. init drops its unreachable no-folders prompt. GitHubMetadata and CloudMetadata share a base TypedDict. config list counts models through count_model_files, which now walks once and counts files only. --- kicad_lib_manager/commands/config/command.py | 38 ++++++----- kicad_lib_manager/commands/init/command.py | 47 ++++---------- kicad_lib_manager/services/library_service.py | 14 ++--- kicad_lib_manager/utils/metadata.py | 63 +++++++++++-------- 4 files changed, 72 insertions(+), 90 deletions(-) diff --git a/kicad_lib_manager/commands/config/command.py b/kicad_lib_manager/commands/config/command.py index 90379d5..145fa33 100644 --- a/kicad_lib_manager/commands/config/command.py +++ b/kicad_lib_manager/commands/config/command.py @@ -16,9 +16,13 @@ from kicad_lib_manager.utils.constants import ( CLOUD_METADATA_FILE, GITHUB_METADATA_FILE, - MODEL_EXTENSIONS, ) -from kicad_lib_manager.utils.metadata import read_cloud_metadata, read_github_metadata +from kicad_lib_manager.utils.metadata import ( + LIBRARY_FOLDERS, + count_model_files, + read_cloud_metadata, + read_github_metadata, +) @dataclass(frozen=True) @@ -184,15 +188,11 @@ def _github_details(path: Path) -> list[str]: metadata.get("description"), metadata.get("version"), metadata.get("env_var") ) capabilities = metadata.get("capabilities") - if isinstance(capabilities, dict): + if capabilities is not None: features = [ - label - for key, label in ( - ("symbols", "Symbols"), - ("footprints", "Footprints"), - ("templates", "Templates"), - ) - if capabilities.get(key) + folder.capitalize() + for folder in LIBRARY_FOLDERS + if capabilities.get(folder) ] lines.append(f"[white]Features:[/white] {', '.join(features) or 'none'}") return lines @@ -217,25 +217,23 @@ def _cloud_details(path: Path) -> list[str]: return lines -def _common_details(description: object, version: object, env_var: object) -> list[str]: +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(str(description))}") + lines.append(f"[green]Description:[/green] {escape(description)}") if version is not None: - lines.append(f"[yellow]Version:[/yellow] {escape(str(version))}") - if env_var: - lines.append(f"[magenta]Environment Variable:[/magenta] {escape(str(env_var))}") + 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 = sum( - 1 - for file in path.rglob("*") - if file.suffix in MODEL_EXTENSIONS and file.is_file() - ) + count = count_model_files(path) return f"[white]3D Models:[/white] {count} [dim](counted)[/dim]" diff --git a/kicad_lib_manager/commands/init/command.py b/kicad_lib_manager/commands/init/command.py index 878caa8..e02c284 100644 --- a/kicad_lib_manager/commands/init/command.py +++ b/kicad_lib_manager/commands/init/command.py @@ -10,12 +10,11 @@ import typer from rich.panel import Panel -from rich.prompt import Confirm 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.kicad_common import as_json_object +from kicad_lib_manager.utils.metadata import LIBRARY_FOLDERS LIBRARY_DESCRIPTIONS_FILE = "library_descriptions.yaml" LIBRARY_DESCRIPTIONS_TEMPLATE = """\ @@ -36,20 +35,13 @@ """ -def _sort_folders( - capabilities: object, present_before: set[str] -) -> tuple[list[str], list[str]]: +def _sort_folders(present_before: set[str]) -> tuple[list[str], list[str]]: """Split the library folders into those that existed and those just created. - ``capabilities`` maps folder names to whether the library has them, as - stored in the metadata; ``present_before`` names the directory entries - from before initialization. + ``present_before`` names the directory entries from before initialization. """ - existing: list[str] = [] - created: list[str] = [] - for folder, present in (as_json_object(capabilities) or {}).items(): - if present: - (existing if folder in present_before else created).append(folder) + 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 @@ -71,18 +63,12 @@ def _write_descriptions_template(directory: Path) -> None: def _report_folders(existing: list[str], created: list[str]) -> None: - """Print the folder status; exit when there are none and the user stops.""" + """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)}") - if existing or created: - return - 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) def _success_message( @@ -149,20 +135,13 @@ def init( # noqa: PLR0913 -- each CLI option is a Typer parameter ), ] = 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() @@ -191,7 +170,7 @@ def init( # noqa: PLR0913 -- each CLI option is a Typer parameter raise typer.Exit(1) from e _write_descriptions_template(current_dir) - _report_folders(*_sort_folders(metadata.get("capabilities"), present_before)) + _report_folders(*_sort_folders(present_before)) library_name = str(metadata.get("name") or current_dir.name) try: diff --git a/kicad_lib_manager/services/library_service.py b/kicad_lib_manager/services/library_service.py index e537873..27afa3a 100644 --- a/kicad_lib_manager/services/library_service.py +++ b/kicad_lib_manager/services/library_service.py @@ -1,23 +1,21 @@ """Create KiLM libraries and register their contents with KiCad.""" from pathlib import Path -from typing import Final 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, + library_capabilities, read_github_metadata, write_github_metadata, ) -LIBRARY_FOLDERS: Final = ("symbols", "footprints", "templates") -"""The top-level folders of a KiLM library, created by `initialize_library`.""" - class LibraryService: """Library operations behind the init, list, pin, setup, status, and unpin commands. @@ -82,12 +80,8 @@ def initialize_library( # noqa: PLR0913 -- one parameter per init command optio for folder in LIBRARY_FOLDERS: (directory / folder).mkdir(parents=True, exist_ok=True) - # Every folder exists now, so the library can hold each kind. - metadata["capabilities"] = { - "symbols": True, - "footprints": True, - "templates": True, - } + # Every folder exists now, so every capability is set. + metadata["capabilities"] = library_capabilities(directory) if not write_github_metadata(directory, metadata): msg = f"Could not write {GITHUB_METADATA_FILE} in {directory}" raise OSError(msg) diff --git a/kicad_lib_manager/utils/metadata.py b/kicad_lib_manager/utils/metadata.py index 08ae14f..0cdc795 100644 --- a/kicad_lib_manager/utils/metadata.py +++ b/kicad_lib_manager/utils/metadata.py @@ -15,7 +15,7 @@ import re from collections.abc import Callable, Mapping from pathlib import Path -from typing import TypedDict, cast +from typing import Final, TypedDict, cast import yaml @@ -30,6 +30,7 @@ __all__ = [ "CLOUD_METADATA_FILE", "GITHUB_METADATA_FILE", + "LIBRARY_FOLDERS", "CloudMetadata", "GitHubMetadata", "LibraryCapabilities", @@ -37,6 +38,7 @@ "generate_env_var_name", "get_default_cloud_metadata", "get_default_github_metadata", + "library_capabilities", "read_cloud_metadata", "read_github_metadata", "write_cloud_metadata", @@ -44,6 +46,13 @@ ] +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.""" @@ -52,8 +61,8 @@ class LibraryCapabilities(TypedDict, total=False): templates: bool -class GitHubMetadata(TypedDict, total=False): - """kilm.yaml of a symbol/footprint library. Every field may be absent. +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. @@ -64,22 +73,20 @@ class GitHubMetadata(TypedDict, total=False): type: str version: str env_var: str | None - capabilities: LibraryCapabilities created_with: str updated_with: str -class CloudMetadata(TypedDict, total=False): +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.""" - name: str - description: str - type: str - version: str - env_var: str | None model_count: int - created_with: str - updated_with: str FieldCheck = Callable[[object], bool] @@ -102,10 +109,7 @@ 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 LibraryCapabilities.__annotations__ - ) + return all(isinstance(flags.get(key, False), bool) for key in LIBRARY_FOLDERS) _COMMON_FIELDS: dict[str, FieldCheck] = { @@ -232,8 +236,8 @@ def generate_env_var_name(name: str, prefix: str = "") -> str: def get_default_github_metadata(directory: Path) -> GitHubMetadata: """Metadata for a new library in directory, named after the directory. - Capabilities reflect which of the symbols, footprints, and templates - directories exist; env_var is KICAD_LIB_. + Capabilities come from `library_capabilities`; env_var is + KICAD_LIB_. """ name = directory.name return { @@ -242,23 +246,30 @@ def get_default_github_metadata(directory: Path) -> GitHubMetadata: "type": "github", "version": "1.0.0", "env_var": generate_env_var_name(name, "KICAD_LIB"), - "capabilities": { - "symbols": (directory / "symbols").exists(), - "footprints": (directory / "footprints").exists(), - "templates": (directory / "templates").exists(), - }, + "capabilities": library_capabilities(directory), "created_with": "kilm", "updated_with": "kilm", } +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(), + } + + def count_model_files(directory: Path) -> int: - """Return the number of entries below ``directory`` named like 3D models. + """Return the number of files below ``directory`` named like 3D models. - Matches MODEL_EXTENSIONS case-sensitively at any depth. + Matches MODEL_EXTENSIONS case-sensitively at any depth, in one walk. """ return sum( - len(list(directory.glob(f"**/*{extension}"))) for extension in MODEL_EXTENSIONS + 1 + for file in directory.rglob("*") + if file.suffix in MODEL_EXTENSIONS and file.is_file() ) From fc2f5ea9ef8b7e8e524377aba713637814e52958 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:46:05 +0200 Subject: [PATCH 087/105] fix: PyPI answers whose JSON body is not an object count as no version check_latest_version promises never to raise, but a list or null body raised AttributeError, which crashed kilm update check. --- kicad_lib_manager/services/pypi_version.py | 3 ++- tests/test_pypi_version.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/kicad_lib_manager/services/pypi_version.py b/kicad_lib_manager/services/pypi_version.py index 8bdecf3..a17ff90 100644 --- a/kicad_lib_manager/services/pypi_version.py +++ b/kicad_lib_manager/services/pypi_version.py @@ -117,7 +117,8 @@ def _read_response( return CachedVersion(cached.version, cached.etag, time.time()) if response.status_code != HTTPStatus.OK: return None - info = response.json().get("info") + 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 diff --git a/tests/test_pypi_version.py b/tests/test_pypi_version.py index f1af03f..56dcd1b 100644 --- a/tests/test_pypi_version.py +++ b/tests/test_pypi_version.py @@ -66,6 +66,9 @@ def test_unexpected_response_is_unknown( 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( From 6b03dc2b800ebe638503dd8f92c511ab6bf4c0be Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:46:39 +0200 Subject: [PATCH 088/105] docs: correct update-check, PyPI cache, sync, unpin, and update perform docstrings pypi_version caches under user_paths.user_cache_dir() instead of rebuilding the path. update perform help now covers --target-version and the installer fallback. --- kicad_lib_manager/commands/sync/command.py | 4 ++-- kicad_lib_manager/commands/unpin/command.py | 8 +++++++- kicad_lib_manager/commands/update/perform.py | 15 +++++++-------- kicad_lib_manager/main.py | 5 +++-- kicad_lib_manager/services/config_service.py | 3 ++- kicad_lib_manager/services/pypi_version.py | 14 ++++++++------ kicad_lib_manager/services/update_service.py | 2 +- 7 files changed, 30 insertions(+), 21 deletions(-) diff --git a/kicad_lib_manager/commands/sync/command.py b/kicad_lib_manager/commands/sync/command.py index 4aa0e92..30351eb 100644 --- a/kicad_lib_manager/commands/sync/command.py +++ b/kicad_lib_manager/commands/sync/command.py @@ -227,8 +227,8 @@ def sync( 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. - The kinds are ``symbols``, ``footprints``, and ``templates``: content that - KiCad only sees after ``kilm setup``. Added, modified, and renamed paths + 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. diff --git a/kicad_lib_manager/commands/unpin/command.py b/kicad_lib_manager/commands/unpin/command.py index db06dc6..b31dfda 100644 --- a/kicad_lib_manager/commands/unpin/command.py +++ b/kicad_lib_manager/commands/unpin/command.py @@ -169,7 +169,13 @@ def unpin( # noqa: PLR0913 -- each CLI option is a Typer parameter typer.Option("--verbose", "-v", help="Show verbose output for debugging"), ] = False, ) -> None: - """Unpin libraries in KiCad.""" + """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 [] diff --git a/kicad_lib_manager/commands/update/perform.py b/kicad_lib_manager/commands/update/perform.py index 671c7c7..c175672 100644 --- a/kicad_lib_manager/commands/update/perform.py +++ b/kicad_lib_manager/commands/update/perform.py @@ -44,16 +44,15 @@ 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. """ console = get_console() console.print(Panel(BREAKING_CHANGE_NOTICE, expand=False, border_style="yellow")) diff --git a/kicad_lib_manager/main.py b/kicad_lib_manager/main.py index 0553b34..f9798c5 100644 --- a/kicad_lib_manager/main.py +++ b/kicad_lib_manager/main.py @@ -46,8 +46,9 @@ def _show_title() -> None: def _check_for_updates_if_needed() -> None: """Show a notice when a newer KiLM is available, unless disabled. - PyPI is queried only as often as the configured frequency allows; between - queries the cached answer still produces the notice. Any failure is logged + 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. """ diff --git a/kicad_lib_manager/services/config_service.py b/kicad_lib_manager/services/config_service.py index 0f44c5a..4d13a5e 100644 --- a/kicad_lib_manager/services/config_service.py +++ b/kicad_lib_manager/services/config_service.py @@ -35,7 +35,8 @@ class LibraryDict(TypedDict): } """Seconds between update checks for each `update_check_frequency`. -"never" disables checks; any other value never triggers one. +"never" disables checks; any other value triggers one only while no +previous check is recorded. """ LAST_UPDATE_CHECK_FILE: Final = "last_update_check" diff --git a/kicad_lib_manager/services/pypi_version.py b/kicad_lib_manager/services/pypi_version.py index a17ff90..32a23e4 100644 --- a/kicad_lib_manager/services/pypi_version.py +++ b/kicad_lib_manager/services/pypi_version.py @@ -12,6 +12,8 @@ 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.""" @@ -19,9 +21,8 @@ """How long a PyPI request may take before the check gives up.""" -def default_cache_file() -> Path: - """Return where the version check is cached for the current user.""" - return Path.home() / ".cache" / "kilm" / "version_check.json" +VERSION_CHECK_FILE: Final = "version_check.json" +"""Cache file, in the user cache directory, holding the last PyPI answer.""" @dataclass(frozen=True) @@ -73,15 +74,16 @@ def __init__( 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 default_cache_file() + 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 and never raise, so an - offline machine only loses the update notice. + 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(): diff --git a/kicad_lib_manager/services/update_service.py b/kicad_lib_manager/services/update_service.py index dc40537..e5869c0 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -34,7 +34,7 @@ class UpdateStatus(TypedDict): """Whether a release newer than the installed version exists.""" current_version: str latest_version: str | None - """None when PyPI could not be asked and nothing was cached.""" + """None when no fresh cached answer was used and the PyPI lookup failed.""" method: InstallMethod supports_auto_update: bool From b58d61235792f16c5e5b8a1c8d335e3b8b815b65 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:47:02 +0200 Subject: [PATCH 089/105] refactor: name library-path discovery for what it searches find_environment_variables becomes find_library_variable (it returns one value from config, cwd metadata, environment, and shell files), and setup's fallback and source bucket follow. Printed labels are unchanged. --- kicad_lib_manager/commands/setup/command.py | 4 +-- kicad_lib_manager/commands/setup/sources.py | 29 ++++++++++++--------- kicad_lib_manager/commands/shared.py | 6 ++--- kicad_lib_manager/utils/env_vars.py | 4 +-- tests/test_env_vars.py | 12 ++++----- tests/test_setup_command.py | 2 +- tests/test_sync_command.py | 2 +- 7 files changed, 32 insertions(+), 27 deletions(-) diff --git a/kicad_lib_manager/commands/setup/command.py b/kicad_lib_manager/commands/setup/command.py index f6d56a1..adbcffd 100644 --- a/kicad_lib_manager/commands/setup/command.py +++ b/kicad_lib_manager/commands/setup/command.py @@ -14,7 +14,7 @@ LibrarySources, add_config_libraries, command_line_sources, - fall_back_to_environment, + fall_back_to_discovery, print_source_summary, requested_names, ) @@ -115,7 +115,7 @@ def setup( # noqa: PLR0913 -- each CLI option is a Typer parameter ), ) _read_config(sources, selection, verbose=verbose) - symbol_dir = fall_back_to_environment(sources) + symbol_dir = fall_back_to_discovery(sources) if verbose: print_source_summary(sources) diff --git a/kicad_lib_manager/commands/setup/sources.py b/kicad_lib_manager/commands/setup/sources.py index 4536171..a0e51b7 100644 --- a/kicad_lib_manager/commands/setup/sources.py +++ b/kicad_lib_manager/commands/setup/sources.py @@ -1,7 +1,7 @@ """Where ``kilm setup`` finds the libraries it configures. -The command line comes first, then the KiLM configuration, then environment -variables. +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 @@ -16,7 +16,7 @@ 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_environment_variables +from kicad_lib_manager.utils.env_vars import find_library_variable from kicad_lib_manager.utils.metadata import read_cloud_metadata, read_github_metadata @@ -30,14 +30,15 @@ class LibrarySources: 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. - from_environment: Directories found in environment variables. + 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) - from_environment: dict[str, str] = field(default_factory=dict) + discovered: dict[str, str] = field(default_factory=dict) @dataclass(frozen=True) @@ -230,8 +231,12 @@ def _environment_variable( return None, "[yellow]Not configured[/yellow]" -def fall_back_to_environment(sources: LibrarySources) -> str: - """Fill missing main directories from environment variables. +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. @@ -242,7 +247,7 @@ def fall_back_to_environment(sources: LibrarySources) -> str: console = get_console() symbol_dir = sources.symbol_dir if symbol_dir is None: - symbol_dir = given(find_environment_variables(SYMBOL_DIR_VAR)) + 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( @@ -251,14 +256,14 @@ def fall_back_to_environment(sources: LibrarySources) -> str: ) raise typer.Exit(1) sources.symbol_dir = symbol_dir - sources.from_environment[SYMBOL_DIR_VAR] = 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_environment_variables(MODELS_DIR_VAR)) + 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 " @@ -270,7 +275,7 @@ def fall_back_to_environment(sources: LibrarySources) -> str: ) else: sources.models_dir = models_dir - sources.from_environment[MODELS_DIR_VAR] = 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]" @@ -285,7 +290,7 @@ def print_source_summary(sources: LibrarySources) -> None: for heading, paths in ( ("From command line", sources.from_command_line), ("From config file", sources.from_config), - ("From environment variables", sources.from_environment), + ("From environment variables", sources.discovered), ): if paths: console.print(f" {heading}:") diff --git a/kicad_lib_manager/commands/shared.py b/kicad_lib_manager/commands/shared.py index dba514f..76da55e 100644 --- a/kicad_lib_manager/commands/shared.py +++ b/kicad_lib_manager/commands/shared.py @@ -6,7 +6,7 @@ from kicad_lib_manager.utils.constants import SYMBOL_DIR_VAR from kicad_lib_manager.utils.env_vars import ( expand_user_path, - find_environment_variables, + find_library_variable, ) @@ -37,10 +37,10 @@ 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_environment_variables`` searches. The value is not checked to be a + ``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_environment_variables(SYMBOL_DIR_VAR)) + 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) diff --git a/kicad_lib_manager/utils/env_vars.py b/kicad_lib_manager/utils/env_vars.py index 8b42334..231400e 100644 --- a/kicad_lib_manager/utils/env_vars.py +++ b/kicad_lib_manager/utils/env_vars.py @@ -1,6 +1,6 @@ """Library paths from the user's environment, and KiCad's kicad_common.json. -find_environment_variables looks a library path up the way a user would set +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. @@ -56,7 +56,7 @@ class _LibraryVariable(NamedTuple): _SHELL_FILES = (".bashrc", ".bash_profile", ".profile", ".zshrc") -def find_environment_variables(var_name: str) -> str | None: +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 diff --git a/tests/test_env_vars.py b/tests/test_env_vars.py index 626f1a6..349c7e3 100644 --- a/tests/test_env_vars.py +++ b/tests/test_env_vars.py @@ -8,7 +8,7 @@ from kicad_lib_manager.utils import env_vars from kicad_lib_manager.utils.env_vars import ( - find_environment_variables, + find_library_variable, update_kicad_env_vars, update_pinned_libraries, ) @@ -179,18 +179,18 @@ 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_environment_variables("KICAD_USER_LIB") is None + 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_environment_variables("KICAD_USER_LIB") == "/from/zshrc" + assert find_library_variable("KICAD_USER_LIB") == "/from/zshrc" monkeypatch.setenv("KICAD_USER_LIB", "/from/environment") - assert find_environment_variables("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_environment_variables("KICAD_USER_LIB") == str(tmp_path.resolve()) + assert find_library_variable("KICAD_USER_LIB") == str(tmp_path.resolve()) monkeypatch.setattr(_FakeConfig, "library_path", "/from/config") - assert find_environment_variables("KICAD_USER_LIB") == "/from/config" + assert find_library_variable("KICAD_USER_LIB") == "/from/config" diff --git a/tests/test_setup_command.py b/tests/test_setup_command.py index aa74383..2db98ae 100644 --- a/tests/test_setup_command.py +++ b/tests/test_setup_command.py @@ -25,7 +25,7 @@ @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_environment_variables", lambda _name: None) + 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") diff --git a/tests/test_sync_command.py b/tests/test_sync_command.py index ddb8f60..4a8125a 100644 --- a/tests/test_sync_command.py +++ b/tests/test_sync_command.py @@ -135,7 +135,7 @@ 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_environment_variables", lambda _name: None) + monkeypatch.setattr(setup_sources, "find_library_variable", lambda _name: None) _commit_file(upstream, "symbols/New.kicad_sym", "add symbols") result = CliRunner().invoke(app, ["sync", "--auto-setup"]) From 8b77a23266edf1fc581be6e8c8bc3f02b6a9b5df Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:47:21 +0200 Subject: [PATCH 090/105] refactor: one KiCad version ordering for config lookup and kicad-cli discovery --- kicad_lib_manager/commands/import_zip/kicad_cli.py | 10 ++++------ kicad_lib_manager/services/kicad_config.py | 11 +++++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/kicad_lib_manager/commands/import_zip/kicad_cli.py b/kicad_lib_manager/commands/import_zip/kicad_cli.py index c53ce8f..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 @@ -10,6 +9,7 @@ from pathlib import Path 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, ...] = ( @@ -30,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: diff --git a/kicad_lib_manager/services/kicad_config.py b/kicad_lib_manager/services/kicad_config.py index acf3cb0..2e604d4 100644 --- a/kicad_lib_manager/services/kicad_config.py +++ b/kicad_lib_manager/services/kicad_config.py @@ -72,7 +72,7 @@ def find_kicad_config( msg = f"No KiCad version directories found in {root}. {RUN_KICAD_FIRST}" raise FileNotFoundError(msg) - latest = max(versions, key=lambda entry: (_version_key(entry), entry.name)) + 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() @@ -82,6 +82,9 @@ def find_kicad_config( return latest -def _version_key(version_dir: Path) -> tuple[int, ...]: - """Return the numbers in a version directory name, for numeric ordering.""" - return tuple(int(part) for part in re.findall(r"\d+", version_dir.name)) +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)) From 7e5c2b193a912c573cb9d4eaad305948eeae18ba Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:47:56 +0200 Subject: [PATCH 091/105] test: pin exact config removals, a non-first interactive pick, and INSTALLER wiring --- tests/test_config_commands.py | 30 +++++++++++++++++++----------- tests/test_install_method.py | 15 ++++++++++++++- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/tests/test_config_commands.py b/tests/test_config_commands.py index 9dcf640..094664c 100644 --- a/tests/test_config_commands.py +++ b/tests/test_config_commands.py @@ -157,14 +157,18 @@ def test_set_default_prompts_without_name( ) -> 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\n1\n") + result = invoke("set-default", *arguments, user_input="5\n2\n") assert result.exit_code == 0 assert f"Available {library['type']} libraries:" in result.output assert f"1. {library['name']}" in result.output - assert "5 is not in the range 1<=x<=1" in result.output - assert stored(config_file).get_current_library() == library["path"] + 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"] @pytest.mark.usefixtures("config_file") @@ -207,7 +211,9 @@ def test_remove_default_library_reports_cleared_default(config_file: Path) -> No assert stored(config_file).get_current_library() is None -def test_remove_asks_for_confirmation(config_file: Path) -> 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") @@ -215,7 +221,7 @@ def test_remove_asks_for_confirmation(config_file: Path) -> None: 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 len(stored(config_file).get_libraries()) == 1 + assert stored(config_file).get_libraries() == [libraries[1]] def test_remove_cancelled_keeps_library(config_file: Path) -> None: @@ -227,7 +233,9 @@ def test_remove_cancelled_keeps_library(config_file: Path) -> None: assert len(stored(config_file).get_libraries()) == 2 -def test_remove_only_the_given_type(config_file: Path) -> None: +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") @@ -235,13 +243,12 @@ def test_remove_only_the_given_type(config_file: Path) -> None: result = invoke("remove", "test-github-lib", "--type", "cloud", "--force") assert result.exit_code == 0 - assert [library["type"] for library in stored(config_file).get_libraries()] == [ - "github", - "cloud", - ] + assert stored(config_file).get_libraries() == libraries -def test_remove_every_type_reports_count(config_file: Path) -> None: +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") @@ -249,6 +256,7 @@ def test_remove_every_type_reports_count(config_file: Path) -> None: 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]] @pytest.mark.usefixtures("config_file") diff --git a/tests/test_install_method.py b/tests/test_install_method.py index b67bc61..36266f4 100644 --- a/tests/test_install_method.py +++ b/tests/test_install_method.py @@ -153,10 +153,23 @@ def test_pip_installer_record(self) -> None: def test_running_interpreter_and_metadata( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """detect_installation_method inspects the running interpreter.""" + """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.""" From c802a637766c4123f2ffd973f855a4412eb2f8ce Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:48:04 +0200 Subject: [PATCH 092/105] docs(backup): state that name order is creation order only for a forward-moving clock --- kicad_lib_manager/utils/backup.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/kicad_lib_manager/utils/backup.py b/kicad_lib_manager/utils/backup.py index 4106ff0..8b3b4dd 100644 --- a/kicad_lib_manager/utils/backup.py +++ b/kicad_lib_manager/utils/backup.py @@ -1,8 +1,10 @@ """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 the -name order of a file's backups is their creation order; pruning relies on it. +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. """ @@ -46,8 +48,8 @@ def unique_backup_path(file_path: Path) -> Path: """Return an unused backup path for file_path, named after the current time. When the name is taken (clocks can repeat a reading), the stamp moves - forward a microsecond at a time, which keeps name order equal to creation - order. + forward a microsecond at a time, so the new name still sorts after the + one it collided with. """ stamp = datetime.now().astimezone() while (path := _backup_path(file_path, stamp)).exists(): @@ -58,7 +60,7 @@ def unique_backup_path(file_path: Path) -> Path: def manage_backup_history( file_path: Path, max_backups: int = DEFAULT_MAX_BACKUPS ) -> None: - """Delete the oldest backups of file_path beyond the newest max_backups.""" + """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() From 6454876b4540b8f9ff400c210b3f7eb4b380123a Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 00:48:36 +0200 Subject: [PATCH 093/105] test: collapse the four mocked git hooks-path cases into relative and absolute --- tests/test_git_utils.py | 83 ++++++++--------------------------------- 1 file changed, 16 insertions(+), 67 deletions(-) diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index e62e845..7170b26 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -79,77 +79,26 @@ def test_merge_hook_content_replace_existing(self) -> None: 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: Mock, tmp_path: Path + def test_get_git_hooks_directory_asks_git( + self, mock_run: Mock, tmp_path: Path, *, relative: bool ) -> None: - """Test standard hooks directory detection.""" - # Mock the first call to succeed with hooks path - mock_run.return_value = Mock(returncode=0, stdout=".git/hooks") + """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" / "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: Mock, tmp_path: Path - ) -> None: - """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: Mock, tmp_path: Path - ) -> None: - """Test relative custom hooks path handling.""" - mock_run.return_value = Mock(returncode=0, stdout="custom/hooks") - - repo_path = tmp_path / "test-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" - mock_run.assert_called_once() - - @patch("subprocess.run") - def test_get_git_hooks_directory_worktree( - self, mock_run: Mock, tmp_path: Path - ) -> None: - """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 + 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() def test_get_git_hooks_directory_without_git( From b526a49e5d5e17abfd12ae70671e36998ae03bda Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 01:06:52 +0200 Subject: [PATCH 094/105] fix(config): report a config file that is not UTF-8 and use the defaults --- kicad_lib_manager/services/config_service.py | 2 +- tests/test_config_service.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/kicad_lib_manager/services/config_service.py b/kicad_lib_manager/services/config_service.py index 4d13a5e..67d8032 100644 --- a/kicad_lib_manager/services/config_service.py +++ b/kicad_lib_manager/services/config_service.py @@ -85,7 +85,7 @@ def _load_config(self) -> None: loaded = yaml.safe_load(f) except FileNotFoundError: return - except (OSError, yaml.YAMLError) as e: + except (OSError, UnicodeDecodeError, yaml.YAMLError) as e: click.echo(f"Error loading config file: {e}", err=True) return if isinstance(loaded, dict): diff --git a/tests/test_config_service.py b/tests/test_config_service.py index 8c551ac..440b3b7 100644 --- a/tests/test_config_service.py +++ b/tests/test_config_service.py @@ -12,6 +12,7 @@ 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") @@ -88,6 +89,19 @@ def test_unreadable_file_is_reported_and_yields_defaults( 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) From f9e90f48c55e07627dcf3950678be50b15caca1a Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 01:07:31 +0200 Subject: [PATCH 095/105] fix(init): keep kilm.yaml entries with non-string keys or mistyped fields --- kicad_lib_manager/services/library_service.py | 15 ++-- kicad_lib_manager/utils/metadata.py | 79 ++++++++++++++----- tests/test_library_service.py | 20 ++++- 3 files changed, 89 insertions(+), 25 deletions(-) diff --git a/kicad_lib_manager/services/library_service.py b/kicad_lib_manager/services/library_service.py index 27afa3a..15ca95e 100644 --- a/kicad_lib_manager/services/library_service.py +++ b/kicad_lib_manager/services/library_service.py @@ -12,7 +12,7 @@ generate_env_var_name, get_default_github_metadata, library_capabilities, - read_github_metadata, + read_github_metadata_file, write_github_metadata, ) @@ -53,19 +53,21 @@ def initialize_library( # noqa: PLR0913 -- one parameter per init command optio 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. + capabilities and metadata file reflect them. Without force, entries + the metadata cannot hold (see `read_github_metadata_file`) are kept. Raises: OSError: A folder or the metadata file cannot be written. """ overrides = (name, description, env_var) - existing = read_github_metadata(directory) - if force or existing is None or not existing: + 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: - metadata = existing + metadata, unsupported = existing if no_env_var or any(value is not None for value in overrides): metadata["updated_with"] = "kilm" @@ -77,12 +79,13 @@ def initialize_library( # noqa: PLR0913 -- one parameter per init command optio 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): + 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/utils/metadata.py b/kicad_lib_manager/utils/metadata.py index 0cdc795..c1e1cda 100644 --- a/kicad_lib_manager/utils/metadata.py +++ b/kicad_lib_manager/utils/metadata.py @@ -4,6 +4,9 @@ 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. +kilm.yaml entries that the typed view cannot hold at all (non-string keys and +mistyped known fields) come back separately from `read_github_metadata_file` +for `write_github_metadata` to put back. Read and write failures are reported on stderr and returned as None or False, because every caller treats unreadable metadata as missing. """ @@ -15,7 +18,7 @@ import re from collections.abc import Callable, Mapping from pathlib import Path -from typing import Final, TypedDict, cast +from typing import Final, NamedTuple, TypedDict, cast import yaml @@ -33,6 +36,7 @@ "LIBRARY_FOLDERS", "CloudMetadata", "GitHubMetadata", + "GitHubMetadataFile", "LibraryCapabilities", "count_model_files", "generate_env_var_name", @@ -41,6 +45,7 @@ "library_capabilities", "read_cloud_metadata", "read_github_metadata", + "read_github_metadata_file", "write_cloud_metadata", "write_github_metadata", ] @@ -89,6 +94,18 @@ class CloudMetadata(_LibraryMetadata, total=False): 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: GitHubMetadata + unsupported: dict[object, object] + + FieldCheck = Callable[[object], bool] @@ -128,21 +145,23 @@ def _is_capabilities(value: object) -> bool: _CLOUD_FIELDS: dict[str, FieldCheck] = {**_COMMON_FIELDS, "model_count": _is_count} -def _valid_fields( +def _split_fields( document: object, fields: Mapping[str, FieldCheck] -) -> dict[str, object]: - """The string-keyed entries of document, minus known fields of the wrong type. +) -> 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. """ - if not isinstance(document, dict): - return {} - entries = cast("dict[object, object]", document) - return { - key: value - for key, value in entries.items() - if isinstance(key, str) and fields.get(key, _is_anything)(value) - } + 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: @@ -160,6 +179,17 @@ def read_github_metadata(directory: Path) -> GitHubMetadata | None: The metadata (empty when the file holds no mapping), or None when the file is missing or unreadable. """ + loaded = read_github_metadata_file(directory) + return None if loaded is None else loaded.metadata + + +def read_github_metadata_file(directory: Path) -> GitHubMetadataFile | None: + """Read directory's kilm.yaml with the entries its metadata cannot hold. + + Returns: + 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 @@ -169,16 +199,29 @@ def read_github_metadata(directory: Path) -> GitHubMetadata | None: except (OSError, ValueError, yaml.YAMLError) as e: _report(f"Error reading metadata file: {e}") return None - # The known fields were checked against GitHubMetadata just above. - return cast("GitHubMetadata", _valid_fields(document, _GITHUB_FIELDS)) + 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) -> bool: - """Write metadata to directory's kilm.yaml; return whether it succeeded.""" +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: dict[object, object] = { + key: value for key, value in (unsupported or {}).items() if key not in metadata + } + document.update(metadata) metadata_file = directory / GITHUB_METADATA_FILE try: with metadata_file.open("w", encoding="utf-8") as f: - yaml.dump(metadata, f, default_flow_style=False) + yaml.dump(document, f, default_flow_style=False) except (OSError, yaml.YAMLError) as e: _report(f"Error writing metadata file: {e}") return False @@ -202,7 +245,7 @@ def read_cloud_metadata(directory: Path) -> CloudMetadata | None: _report(f"Error reading cloud metadata file: {e}") return None # The known fields were checked against CloudMetadata just above. - return cast("CloudMetadata", _valid_fields(document, _CLOUD_FIELDS)) + return cast("CloudMetadata", _split_fields(document, _CLOUD_FIELDS)[0]) def write_cloud_metadata(directory: Path, metadata: CloudMetadata) -> bool: diff --git a/tests/test_library_service.py b/tests/test_library_service.py index 113be07..11b842d 100644 --- a/tests/test_library_service.py +++ b/tests/test_library_service.py @@ -3,6 +3,7 @@ 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 @@ -49,6 +50,21 @@ def test_existing_metadata_is_kept_with_overrides(tmp_path: Path) -> None: 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"}) @@ -64,7 +80,9 @@ def test_unwritable_metadata_fails( ) -> 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: False + library_service, + "write_github_metadata", + lambda _directory, _metadata, _unsupported: False, ) with pytest.raises(OSError, match=r"kilm\.yaml"): From dcf195b918d0ee08871af4bc5f53bf3e30d83f5d Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 01:08:52 +0200 Subject: [PATCH 096/105] fix(setup): decode escaped library table strings so a second setup adds no duplicate --- kicad_lib_manager/utils/file_ops.py | 107 ++++++++++++++++++++++++---- tests/test_library_table.py | 55 ++++++++++++++ 2 files changed, 149 insertions(+), 13 deletions(-) diff --git a/kicad_lib_manager/utils/file_ops.py b/kicad_lib_manager/utils/file_ops.py index 6e8db8d..14ccd53 100644 --- a/kicad_lib_manager/utils/file_ops.py +++ b/kicad_lib_manager/utils/file_ops.py @@ -6,7 +6,7 @@ import uuid from collections.abc import Sequence from pathlib import Path -from typing import Literal, TypedDict +from typing import Literal, NamedTuple, TypedDict from kicad_lib_manager.utils.constants import FOOTPRINT_TABLE_FILE, SYMBOL_TABLE_FILE @@ -14,13 +14,38 @@ # One (lib ...) entry per line, as KiCad writes library tables. _LIB_ENTRY_RE = re.compile(r"^\s*\(lib\s+(.*?)\)\s*$", re.MULTILINE) -_LIB_NAME_RE = re.compile(r'\(name\s+"([^"]+)"\)') -# Optional properties of an entry; "url" is accepted for "uri" too. -_LIB_PROPERTY_RES: dict[Literal["uri", "type", "description"], re.Pattern[str]] = { - "uri": re.compile(r'\((?:uri|url)\s+"([^"]+)"\)'), - "type": re.compile(r'\(type\s+"([^"]+)"\)'), - "description": re.compile(r'\(descr\s+"([^"]+)"\)'), +# 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): @@ -171,6 +196,8 @@ def list_configured_libraries( """List the libraries in KiCad's sym-lib-table and fp-lib-table. 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: (symbol library entries, footprint library entries) @@ -193,13 +220,67 @@ def _read_lib_table(table_path: Path) -> list[LibraryTableEntry]: return entries +class _Token(NamedTuple): + """A token of a table entry; text is decoded when quoted.""" + + text: str + quoted: bool + + +_OPEN = _Token("(", quoted=False) +_CLOSE = _Token(")", quoted=False) + + def _parse_lib_entry(entry: str) -> LibraryTableEntry | None: - name = _LIB_NAME_RE.search(entry) + """Read the `(keyword value)` properties of one entry's content. + + 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": str(name.group(1))} - for key, pattern in _LIB_PROPERTY_RES.items(): - value = pattern.search(entry) - if value is not None: - parsed[key] = str(value.group(1)) + 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/tests/test_library_table.py b/tests/test_library_table.py index fe6ca97..7d313ee 100644 --- a/tests/test_library_table.py +++ b/tests/test_library_table.py @@ -1,5 +1,6 @@ """Tests for adding KiLM libraries to KiCad's library tables.""" +import sys from pathlib import Path import pytest @@ -89,6 +90,45 @@ def test_add_entries_escapes_quotes_and_backslashes(tmp_path: Path) -> None: 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"): @@ -140,6 +180,21 @@ def test_add_libraries_writes_new_entries_once( 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, From e3313240c0ea193ef3653c800d91060333649932 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 01:10:24 +0200 Subject: [PATCH 097/105] fix(template): keep custom metadata.yaml keys for hooks and list --json --- kicad_lib_manager/commands/template/docs.mdx | 2 +- .../commands/template/listing.py | 3 +- kicad_lib_manager/utils/template/hooks.py | 6 ++- kicad_lib_manager/utils/template/models.py | 53 ++++++++++++++++--- tests/test_template.py | 32 +++++++++++ tests/test_template_commands.py | 12 +++++ 6 files changed, 98 insertions(+), 10 deletions(-) diff --git a/kicad_lib_manager/commands/template/docs.mdx b/kicad_lib_manager/commands/template/docs.mdx index bbc1798..6d4e6c1 100644 --- a/kicad_lib_manager/commands/template/docs.mdx +++ b/kicad_lib_manager/commands/template/docs.mdx @@ -108,7 +108,7 @@ kilm template create [project_name_or_path] [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`. #### Examples diff --git a/kicad_lib_manager/commands/template/listing.py b/kicad_lib_manager/commands/template/listing.py index 1f1a554..c6d8e70 100644 --- a/kicad_lib_manager/commands/template/listing.py +++ b/kicad_lib_manager/commands/template/listing.py @@ -41,7 +41,8 @@ def list_templates( return if json_output: # Plain echo: a Rich console would wrap long values and break the JSON. - typer.echo(json.dumps(templates, indent=2)) + # Custom keys may hold YAML dates and such, which print as text. + typer.echo(json.dumps(templates, indent=2, default=str)) return by_library: dict[str, list[tuple[str, DiscoveredTemplate]]] = {} diff --git a/kicad_lib_manager/utils/template/hooks.py b/kicad_lib_manager/utils/template/hooks.py index f3a4f83..cfdb857 100644 --- a/kicad_lib_manager/utils/template/hooks.py +++ b/kicad_lib_manager/utils/template/hooks.py @@ -55,7 +55,11 @@ def post_create(context): class HookContext(TypedDict): - """The argument passed to a template's ``post_create`` function.""" + """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 diff --git a/kicad_lib_manager/utils/template/models.py b/kicad_lib_manager/utils/template/models.py index 26c139f..b7674c7 100644 --- a/kicad_lib_manager/utils/template/models.py +++ b/kicad_lib_manager/utils/template/models.py @@ -3,9 +3,9 @@ # PEP 810: on Python 3.15+ these imports load on first use. __lazy_modules__ = {"yaml"} -from collections.abc import Mapping +from collections.abc import Collection, Mapping from pathlib import Path -from typing import TypeAlias, TypedDict +from typing import TypeAlias, TypedDict, cast import yaml @@ -52,6 +52,17 @@ class DiscoveredTemplate(TemplateMetadata, total=True): 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__ +) + + class MetadataError(Exception): """A ``metadata.yaml`` file could not be read or parsed.""" @@ -79,8 +90,10 @@ def parse_template_metadata(raw: object) -> TemplateMetadata | None: Known keys are kept with their declared types: scalars become strings, a variable without a description or default gets the ones ``template create`` - shows, and malformed values are dropped. Unknown keys are dropped. Returns - None unless ``raw`` is a non-empty mapping. + shows, and malformed values are dropped. Other keys, also inside a + variable or ``dependencies``, are kept as loaded 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 = _string_keyed(raw) if fields is None or not fields: @@ -100,7 +113,11 @@ def parse_template_metadata(raw: object) -> TemplateMetadata | None: metadata["variables"] = _variables(variables) if (dependencies := _string_keyed(fields.get("dependencies"))) is not None: metadata["dependencies"] = _dependencies(dependencies) - return metadata + # TypedDicts allow extra keys; the declared ones were checked above. + return cast( + "TemplateMetadata", + _with_custom_keys(dict(metadata), fields, _TEMPLATE_KEYS), + ) def write_template_metadata(directory: Path, metadata: TemplateMetadata) -> None: @@ -133,24 +150,46 @@ def _text(value: object) -> str | None: return str(value) +def _with_custom_keys( + validated: dict[str, object], + fields: Mapping[str, object], + declared: Collection[str], +) -> dict[str, object]: + """Return ``validated`` followed by the ``fields`` that are not ``declared``.""" + return { + **validated, + **{key: value for key, value in fields.items() if key not in declared}, + } + + def _variables(raw: dict[str, object]) -> dict[str, TemplateVariable]: variables: dict[str, TemplateVariable] = {} for name, value in raw.items(): fields = _string_keyed(value) or {} description = _text(fields.get("description")) default = _text(fields.get("default")) - variables[name] = TemplateVariable( + variable = TemplateVariable( description=default_description(name) if description is None else description, default="" if default is None else default, ) + # TypedDicts allow extra keys; the declared ones were set above. + variables[name] = cast( + "TemplateVariable", + _with_custom_keys(dict(variable), fields, _VARIABLE_KEYS), + ) return variables def _dependencies(raw: dict[str, object]) -> TemplateDependencies: recommended = raw.get("recommended") names = recommended if isinstance(recommended, list) else [] - return TemplateDependencies( + dependencies = TemplateDependencies( recommended=[text for item in names if (text := _text(item)) is not None] ) + # TypedDicts allow extra keys; the declared one was set above. + return cast( + "TemplateDependencies", + _with_custom_keys(dict(dependencies), raw, _DEPENDENCY_KEYS), + ) diff --git a/tests/test_template.py b/tests/test_template.py index a414c67..00e8aa9 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -9,6 +9,7 @@ from kicad_lib_manager.utils.constants import ( HOOKS_DIR, TEMPLATE_CONTENT_DIR, + TEMPLATE_METADATA, TEMPLATES_DIR, ) from kicad_lib_manager.utils.template.authoring import ( @@ -187,6 +188,37 @@ def test_create_project_from_template( 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_create_project_dry_run_writes_nothing( template: DiscoveredTemplate, tmp_path: Path ) -> None: diff --git a/tests/test_template_commands.py b/tests/test_template_commands.py index 75bc466..28d857e 100644 --- a/tests/test_template_commands.py +++ b/tests/test_template_commands.py @@ -64,6 +64,18 @@ def test_list_json_is_valid_json_for_long_values(home: Path) -> None: 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_create_uses_template_from_requested_library( home: Path, tmp_path: Path ) -> None: From 37c313f07e3f835082f1b5aa67d0459eb3a4942b Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 01:10:28 +0200 Subject: [PATCH 098/105] docs(agents): locate kicad_common.py under utils --- AGENTS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 443386a..443ce5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,12 +40,13 @@ files; tests run in `just check`. copies each `docs.mdx` into the site's CLI reference, so update it in the same 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` and `kicad_common.py`: option helpers - and typed `kicad_common.json` access shared by several commands. +- `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, and `console.py`. + `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 From 35e6a9d967ca9f4710f18eae6ba8c9a8fe09178a Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 01:10:59 +0200 Subject: [PATCH 099/105] fix(metadata): replace kilm.yaml and .kilm_metadata atomically --- kicad_lib_manager/utils/metadata.py | 16 ++++++++-------- tests/test_metadata.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/kicad_lib_manager/utils/metadata.py b/kicad_lib_manager/utils/metadata.py index c1e1cda..92ab087 100644 --- a/kicad_lib_manager/utils/metadata.py +++ b/kicad_lib_manager/utils/metadata.py @@ -7,8 +7,9 @@ kilm.yaml entries that the typed view cannot hold at all (non-string keys and mistyped known fields) come back separately from `read_github_metadata_file` for `write_github_metadata` to put back. -Read and write failures are reported on stderr and returned as None or False, -because every caller treats unreadable metadata as missing. +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. """ # PEP 810: on Python 3.15+ these imports load on first use. @@ -29,6 +30,7 @@ MAX_ENV_VAR_NAME_LENGTH, MODEL_EXTENSIONS, ) +from kicad_lib_manager.utils.file_ops import write_atomically __all__ = [ "CLOUD_METADATA_FILE", @@ -218,10 +220,9 @@ def write_github_metadata( key: value for key, value in (unsupported or {}).items() if key not in metadata } document.update(metadata) - metadata_file = directory / GITHUB_METADATA_FILE try: - with metadata_file.open("w", encoding="utf-8") as f: - yaml.dump(document, f, default_flow_style=False) + 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 @@ -250,10 +251,9 @@ def read_cloud_metadata(directory: Path) -> CloudMetadata | None: def write_cloud_metadata(directory: Path, metadata: CloudMetadata) -> bool: """Write metadata to directory's .kilm_metadata; return whether it succeeded.""" - metadata_file = directory / CLOUD_METADATA_FILE try: - with metadata_file.open("w", encoding="utf-8") as f: - json.dump(metadata, f, indent=2) + text = json.dumps(metadata, 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 diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 2764612..84a9586 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -8,8 +8,10 @@ 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: @@ -38,6 +40,22 @@ def test_round_trip_keeps_fields_kilm_does_not_know(tmp_path: Path) -> None: 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: From 363a4012698be9d9d310a06bfd7af4702d2fa3dc Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 01:26:53 +0200 Subject: [PATCH 100/105] fix(add-3d): keep .kilm_metadata fields the typed metadata cannot hold Reading .kilm_metadata dropped known fields of the wrong type (version: 2, description: null), and add-3d rewrote the file without them even when nothing was overridden. read_cloud_metadata_file returns them separately and write_cloud_metadata puts them back unless a field set by the caller replaces them; --force still starts fresh. --- kicad_lib_manager/commands/add_3d/command.py | 74 +++++++++++--------- kicad_lib_manager/utils/metadata.py | 70 ++++++++++++++---- tests/test_add_3d_command.py | 70 ++++++++++++++++++ 3 files changed, 170 insertions(+), 44 deletions(-) create mode 100644 tests/test_add_3d_command.py diff --git a/kicad_lib_manager/commands/add_3d/command.py b/kicad_lib_manager/commands/add_3d/command.py index fff4276..2fd62ff 100644 --- a/kicad_lib_manager/commands/add_3d/command.py +++ b/kicad_lib_manager/commands/add_3d/command.py @@ -16,10 +16,12 @@ 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, ) @@ -48,49 +50,53 @@ def _present(value: str | int | None) -> bool: 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, metadata: CloudMetadata, overrides: _Overrides + directory: Path, existing: CloudMetadataFile, overrides: _Overrides ) -> CloudMetadata: - """Apply ``overrides`` to the directory's metadata and save any change.""" + """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})" ) - name = metadata.get("name") - description = metadata.get("description") + console.print(f"Using existing name: [blue]{metadata.get('name')}[/blue]") env_var = metadata.get("env_var") - console.print(f"Using existing name: [blue]{name}[/blue]") if _present(env_var) and not overrides.no_env_var: console.print( f"Using existing environment variable: [yellow]{env_var}[/yellow]" ) - if overrides.name is not None: - name = overrides.name - console.print(f"Overriding with provided name: [blue]{name}[/blue]") - if overrides.description is not None: - description = overrides.description - console.print( - f"Overriding with provided description: [blue]{description}[/blue]" - ) - if overrides.env_var is not None: - env_var = overrides.env_var - console.print( - f"Overriding with provided environment variable: [yellow]{env_var}[/yellow]" - ) - elif overrides.no_env_var: - console.print("[yellow]Disabling environment variable as requested[/yellow]") - if overrides.given(): - if name is not None: - metadata["name"] = name - if description is not None: - metadata["description"] = description - metadata["env_var"] = ( - env_var if _present(env_var) and not overrides.no_env_var else None - ) + _apply_overrides(metadata, overrides) metadata["updated_with"] = "kilm" - write_cloud_metadata(directory, metadata) + write_cloud_metadata(directory, metadata, unsupported) console.print("[green]Updated metadata file with new information[/green]") return metadata @@ -235,17 +241,21 @@ def add_3d( # noqa: PLR0913 -- each CLI option is a Typer parameter env_var=given(env_var), no_env_var=no_env_var, ) - existing = read_cloud_metadata(directory) - has_existing = existing is not None and len(existing) > 0 + 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: 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) raw_name = metadata.get("name") library_name = directory.name if raw_name is None else str(raw_name) diff --git a/kicad_lib_manager/utils/metadata.py b/kicad_lib_manager/utils/metadata.py index 92ab087..6045fd5 100644 --- a/kicad_lib_manager/utils/metadata.py +++ b/kicad_lib_manager/utils/metadata.py @@ -4,9 +4,9 @@ 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. -kilm.yaml entries that the typed view cannot hold at all (non-string keys and -mistyped known fields) come back separately from `read_github_metadata_file` -for `write_github_metadata` to put back. +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. @@ -37,6 +37,7 @@ "GITHUB_METADATA_FILE", "LIBRARY_FOLDERS", "CloudMetadata", + "CloudMetadataFile", "GitHubMetadata", "GitHubMetadataFile", "LibraryCapabilities", @@ -46,6 +47,7 @@ "get_default_github_metadata", "library_capabilities", "read_cloud_metadata", + "read_cloud_metadata_file", "read_github_metadata", "read_github_metadata_file", "write_cloud_metadata", @@ -108,6 +110,18 @@ class GitHubMetadataFile(NamedTuple): unsupported: dict[object, object] +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] @@ -170,6 +184,21 @@ 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) @@ -216,10 +245,7 @@ def write_github_metadata( Entries of unsupported (from `read_github_metadata_file`) are written too, unless metadata has a field of the same name. """ - document: dict[object, object] = { - key: value for key, value in (unsupported or {}).items() if key not in metadata - } - document.update(metadata) + document = _with_unsupported(metadata, unsupported) try: text = yaml.dump(document, default_flow_style=False) write_atomically(directory / GITHUB_METADATA_FILE, text.encode("utf-8")) @@ -236,6 +262,17 @@ def read_cloud_metadata(directory: Path) -> CloudMetadata | None: The metadata (empty when the file holds no JSON object), or None when the file is missing or unreadable. """ + loaded = read_cloud_metadata_file(directory) + return None if loaded is None else loaded.metadata + + +def read_cloud_metadata_file(directory: Path) -> CloudMetadataFile | None: + """Read directory's .kilm_metadata with the entries its metadata cannot hold. + + Returns: + 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 @@ -245,14 +282,23 @@ def read_cloud_metadata(directory: Path) -> CloudMetadata | None: except (OSError, ValueError) as e: _report(f"Error reading cloud metadata file: {e}") return None - # The known fields were checked against CloudMetadata just above. - return cast("CloudMetadata", _split_fields(document, _CLOUD_FIELDS)[0]) + 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: CloudMetadata) -> bool: - """Write metadata to directory's .kilm_metadata; return whether it succeeded.""" +def write_cloud_metadata( + directory: Path, + metadata: CloudMetadata, + unsupported: Mapping[object, object] | None = None, +) -> bool: + """Write metadata to directory's .kilm_metadata; return whether it succeeded. + + Entries of unsupported (from `read_cloud_metadata_file`) are written + too, unless metadata has a field of the same name. + """ try: - text = json.dumps(metadata, indent=2) + 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}") 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" From 61cfda9cb25361d1587480f26b6ff24f7c47f342 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 01:29:35 +0200 Subject: [PATCH 101/105] fix(template): keep custom metadata.yaml keys as loaded Custom keys were turned into strings, so 7: and '7': collapsed (also inside variables and dependencies) and a hook reading template[7] failed. Keys now stay as YAML loads them; discovery builds the template mapping instead of unpacking metadata as keyword arguments, and template list --json prints keys JSON cannot hold, such as dates, as text instead of failing. --- kicad_lib_manager/commands/template/docs.mdx | 2 +- .../commands/template/listing.py | 24 ++++- kicad_lib_manager/utils/template/discovery.py | 10 +- kicad_lib_manager/utils/template/models.py | 93 +++++++++++-------- tests/test_template.py | 39 ++++++-- tests/test_template_commands.py | 12 +++ 6 files changed, 126 insertions(+), 54 deletions(-) diff --git a/kicad_lib_manager/commands/template/docs.mdx b/kicad_lib_manager/commands/template/docs.mdx index 6d4e6c1..a45ed63 100644 --- a/kicad_lib_manager/commands/template/docs.mdx +++ b/kicad_lib_manager/commands/template/docs.mdx @@ -108,7 +108,7 @@ kilm template create [project_name_or_path] [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. Its `post_create(context)` receives `project_dir`, `variables`, and `template`: the template's metadata, including any keys of your own in `metadata.yaml`. +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 diff --git a/kicad_lib_manager/commands/template/listing.py b/kicad_lib_manager/commands/template/listing.py index c6d8e70..ac75a5c 100644 --- a/kicad_lib_manager/commands/template/listing.py +++ b/kicad_lib_manager/commands/template/listing.py @@ -1,7 +1,8 @@ """The ``template list`` command.""" import json -from typing import Annotated +from collections.abc import Mapping +from typing import Annotated, cast import typer from rich.panel import Panel @@ -42,7 +43,7 @@ def list_templates( 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(templates, indent=2, default=str)) + typer.echo(json.dumps(_json_keys(templates), indent=2, default=str)) return by_library: dict[str, list[tuple[str, DiscoveredTemplate]]] = {} @@ -141,3 +142,22 @@ def _variables_summary(template: DiscoveredTemplate) -> str: 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/utils/template/discovery.py b/kicad_lib_manager/utils/template/discovery.py index b0c40e8..bb1ada2 100644 --- a/kicad_lib_manager/utils/template/discovery.py +++ b/kicad_lib_manager/utils/template/discovery.py @@ -10,6 +10,7 @@ from kicad_lib_manager.utils.template.models import ( DiscoveredTemplate, MetadataError, + discovered_template, read_template_metadata, ) @@ -49,14 +50,7 @@ def list_templates_in_directory(directory: Path) -> list[DiscoveredTemplate]: click.echo(str(error), err=True) continue if metadata is not None: - templates.append( - DiscoveredTemplate( - **metadata, - path=str(template_dir), - source_library=directory.name, - library_path=str(directory), - ) - ) + templates.append(discovered_template(metadata, template_dir, directory)) return templates diff --git a/kicad_lib_manager/utils/template/models.py b/kicad_lib_manager/utils/template/models.py index b7674c7..9a086f1 100644 --- a/kicad_lib_manager/utils/template/models.py +++ b/kicad_lib_manager/utils/template/models.py @@ -5,12 +5,14 @@ from collections.abc import Collection, Mapping from pathlib import Path -from typing import TypeAlias, TypedDict, cast +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] @@ -63,6 +65,23 @@ class DiscoveredTemplate(TemplateMetadata, total=True): ) +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.""" @@ -88,14 +107,16 @@ def read_template_metadata(template_dir: Path) -> TemplateMetadata | None: 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, 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 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. + 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 = _string_keyed(raw) + fields = _mapping(raw) if fields is None or not fields: return None metadata = TemplateMetadata() @@ -109,15 +130,11 @@ def parse_template_metadata(raw: object) -> TemplateMetadata | None: metadata["version"] = version if (extends := _text(fields.get("extends"))) is not None: metadata["extends"] = extends - if (variables := _string_keyed(fields.get("variables"))) is not None: + if (variables := _mapping(fields.get("variables"))) is not None: metadata["variables"] = _variables(variables) - if (dependencies := _string_keyed(fields.get("dependencies"))) is not None: + if (dependencies := _mapping(fields.get("dependencies"))) is not None: metadata["dependencies"] = _dependencies(dependencies) - # TypedDicts allow extra keys; the declared ones were checked above. - return cast( - "TemplateMetadata", - _with_custom_keys(dict(metadata), fields, _TEMPLATE_KEYS), - ) + return _with_custom_keys(metadata, fields, _TEMPLATE_KEYS) def write_template_metadata(directory: Path, metadata: TemplateMetadata) -> None: @@ -136,11 +153,11 @@ def default_description(name: str) -> str: return f"Value for {name}" -def _string_keyed(value: object) -> dict[str, object] | None: - """Return a YAML mapping with its keys as text, or None for other values.""" +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 {str(key): item for key, item in value.items()} + return dict(cast("Mapping[object, object]", value)) def _text(value: object) -> str | None: @@ -151,21 +168,31 @@ def _text(value: object) -> str | None: def _with_custom_keys( - validated: dict[str, object], - fields: Mapping[str, object], + validated: _Validated, + fields: Mapping[object, object], declared: Collection[str], -) -> dict[str, object]: - """Return ``validated`` followed by the ``fields`` that are not ``declared``.""" - return { +) -> _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[str, object]) -> dict[str, TemplateVariable]: +def _variables(raw: dict[object, object]) -> dict[str, TemplateVariable]: variables: dict[str, TemplateVariable] = {} - for name, value in raw.items(): - fields = _string_keyed(value) or {} + 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( @@ -174,22 +201,14 @@ def _variables(raw: dict[str, object]) -> dict[str, TemplateVariable]: else description, default="" if default is None else default, ) - # TypedDicts allow extra keys; the declared ones were set above. - variables[name] = cast( - "TemplateVariable", - _with_custom_keys(dict(variable), fields, _VARIABLE_KEYS), - ) + variables[name] = _with_custom_keys(variable, fields, _VARIABLE_KEYS) return variables -def _dependencies(raw: dict[str, object]) -> TemplateDependencies: +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] ) - # TypedDicts allow extra keys; the declared one was set above. - return cast( - "TemplateDependencies", - _with_custom_keys(dict(dependencies), raw, _DEPENDENCY_KEYS), - ) + return _with_custom_keys(dependencies, raw, _DEPENDENCY_KEYS) diff --git a/tests/test_template.py b/tests/test_template.py index 00e8aa9..3fe13aa 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -20,6 +20,7 @@ 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, @@ -28,6 +29,7 @@ from kicad_lib_manager.utils.template.models import ( DiscoveredTemplate, TemplateMetadata, + discovered_template, read_template_metadata, write_template_metadata, ) @@ -138,12 +140,7 @@ def test_rendered_files_keep_markup_characters(tmp_path: Path) -> None: def _discovered(template_dir: Path, metadata: TemplateMetadata) -> DiscoveredTemplate: - return DiscoveredTemplate( - **metadata, - path=str(template_dir), - source_library="library", - library_path=str(template_dir.parent), - ) + return discovered_template(metadata, template_dir, template_dir.parent) @pytest.fixture @@ -219,6 +216,36 @@ def test_hook_reads_custom_metadata(tmp_path: Path) -> None: 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: diff --git a/tests/test_template_commands.py b/tests/test_template_commands.py index 28d857e..d8dded1 100644 --- a/tests/test_template_commands.py +++ b/tests/test_template_commands.py @@ -76,6 +76,18 @@ def test_list_json_keeps_custom_metadata(home: Path) -> None: 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: From 552cdd6efde2f5820177e93f6a410c7074e6e98e Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 01:29:36 +0200 Subject: [PATCH 102/105] docs(add-3d): describe which .kilm_metadata fields add-3d keeps The page listed a last-updated timestamp that the file never had. --- kicad_lib_manager/commands/add_3d/docs.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kicad_lib_manager/commands/add_3d/docs.mdx b/kicad_lib_manager/commands/add_3d/docs.mdx index 5d6c26b..2e4f030 100644 --- a/kicad_lib_manager/commands/add_3d/docs.mdx +++ b/kicad_lib_manager/commands/add_3d/docs.mdx @@ -32,7 +32,8 @@ The command performs the following main actions: 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` From badd654bece32bc6372942c201c02e927b971974 Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 01:29:51 +0200 Subject: [PATCH 103/105] fix(template): treat an empty variable pattern list as no patterns find_potential_variables used the default patterns for patterns=[]; only None means the defaults now. --- kicad_lib_manager/utils/template/selection.py | 7 +++---- tests/test_template.py | 7 +++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/kicad_lib_manager/utils/template/selection.py b/kicad_lib_manager/utils/template/selection.py index f73b084..8191c9c 100644 --- a/kicad_lib_manager/utils/template/selection.py +++ b/kicad_lib_manager/utils/template/selection.py @@ -157,15 +157,14 @@ def find_potential_variables( Args: directory: The source project. patterns: Regexes whose first group, or whole match when they have no - group, is a candidate value; defaults to DEFAULT_VARIABLE_PATTERNS. + 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. """ - compiled = [ - re.compile(pattern) for pattern in patterns or DEFAULT_VARIABLE_PATTERNS - ] + 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: diff --git a/tests/test_template.py b/tests/test_template.py index 3fe13aa..7bbbf42 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -348,3 +348,10 @@ def test_variable_scan_skips_directories_make_does_not_copy(tmp_path: Path) -> N (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=[]) == {} From 22d90f83ef9c9522480e33dad0d8a35c49d2c7ef Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 01:30:04 +0200 Subject: [PATCH 104/105] fix(update): name kilm update perform in the update notice and release notes Plain kilm update only prints the subcommands. --- .github/scripts/github_release.py | 2 +- kicad_lib_manager/services/update_service.py | 5 ++++- tests/test_update_service.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/scripts/github_release.py b/.github/scripts/github_release.py index 260ba0c..21dd814 100644 --- a/.github/scripts/github_release.py +++ b/.github/scripts/github_release.py @@ -286,7 +286,7 @@ def publication_from_environment(dist_dir: Path) -> Publication: 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, diff --git a/kicad_lib_manager/services/update_service.py b/kicad_lib_manager/services/update_service.py index e5869c0..ff9885c 100644 --- a/kicad_lib_manager/services/update_service.py +++ b/kicad_lib_manager/services/update_service.py @@ -132,7 +132,10 @@ def show_update_notification( ] if status["supports_auto_update"]: lines += [ - "Run [bold cyan]kilm update[/bold cyan] to update automatically", + ( + "Run [bold cyan]kilm update perform[/bold cyan] " + "to update automatically" + ), f"Installation method: [dim]{self.method}[/dim]", ] else: diff --git a/tests/test_update_service.py b/tests/test_update_service.py index 3be2631..7bd7ca6 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -138,7 +138,7 @@ def test_update_available(self, capsys: pytest.CaptureFixture[str]) -> None: assert make_service("pipx").show_update_notification(quiet=True) output = capsys.readouterr().out assert "1.0.0 → Latest: 1.1.0" in output - assert "kilm update" in output + assert "Run kilm update perform to update" in output def test_manual_update_instruction( self, capsys: pytest.CaptureFixture[str] From eb0024105d821f1bdd8608621974c59227ebe6ab Mon Sep 17 00:00:00 2001 From: barisgit Date: Tue, 13 Oct 2026 01:30:13 +0200 Subject: [PATCH 105/105] docs(configuration): config set-default takes a library name, not a path --- docs/src/content/docs/guides/configuration.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/guides/configuration.mdx b/docs/src/content/docs/guides/configuration.mdx index 8848b6b..71ace79 100644 --- a/docs/src/content/docs/guides/configuration.mdx +++ b/docs/src/content/docs/guides/configuration.mdx @@ -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