""" Schematic creation and manipulation tools for the mckicad MCP server. Wraps the SchDocument sexp-tree engine for schematic editing through MCP tools. Designed so that the underlying engine can be swapped to kipy IPC once KiCad exposes a schematic API over its IPC transport. """ import contextlib import logging import os import re from collections import Counter from typing import Any from mckicad.config import INLINE_RESULT_THRESHOLD from mckicad.server import mcp from mckicad.utils.file_utils import write_detail_file from mckicad.utils.lib_resolver import get_symbol_info as _lr_get_info from mckicad.utils.lib_resolver import search_symbols as _lr_search from mckicad.utils.sch_document import SchDocument from mckicad.utils.sch_helpers import expand as _expand from mckicad.utils.sch_helpers import load_schematic from mckicad.utils.sch_helpers import validate_schematic_path as _validate_schematic_path from mckicad.utils.sexp_parser import ( compute_label_placement, resolve_pin_position_and_orientation, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Engine abstraction — swap point for future kipy IPC schematic support # --------------------------------------------------------------------------- def _get_schematic_engine() -> str: """Return the name of the active schematic manipulation engine. Currently: ``sch_document`` (sexp-tree file-level manipulation) Future: ``kipy`` IPC when KiCad adds a schematic API over its IPC transport. """ return "sch_document" # --------------------------------------------------------------------------- # Tools # --------------------------------------------------------------------------- _VALID_PAPER_SIZES = {"A4", "A3", "A2", "A1", "A0", "Letter", "Legal", "Tabloid"} @mcp.tool() def create_schematic( name: str, output_path: str, paper_size: str = "A3", ) -> dict[str, Any]: """Create a new, empty KiCad schematic file. Generates a valid .kicad_sch file at the specified location that can be opened directly in KiCad or extended with add_component / add_wire calls. Args: name: Human-readable name for the schematic (e.g. "Power Supply"). output_path: Destination file path. Must end with .kicad_sch. Parent directory will be created if it does not exist. paper_size: Paper size for the schematic sheet. Default "A3". Valid sizes: A4, A3, A2, A1, A0, Letter, Legal, Tabloid. US schematics typically use A3 or Tabloid (ANSI B, 11x17"). Returns: Dictionary with ``success``, ``path``, ``paper_size``, and ``engine`` keys. """ output_path = _expand(output_path) if not output_path.endswith(".kicad_sch"): return { "success": False, "error": f"output_path must end with .kicad_sch, got: {output_path}", } if paper_size not in _VALID_PAPER_SIZES: return { "success": False, "error": ( f"Invalid paper_size '{paper_size}'. " f"Valid sizes: {', '.join(sorted(_VALID_PAPER_SIZES))}" ), } try: parent = os.path.dirname(output_path) if parent: os.makedirs(parent, exist_ok=True) sch = SchDocument.create(output_path, paper=paper_size) sch.set_title_block(title=name) sch.save(output_path) logger.info("Created schematic '%s' (%s) at %s", name, paper_size, output_path) return { "success": True, "path": output_path, "name": name, "paper_size": paper_size, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to create schematic '%s': %s", name, e) return {"success": False, "error": str(e)} @mcp.tool() def add_component( schematic_path: str, lib_id: str, reference: str, value: str, x: float, y: float, ) -> dict[str, Any]: """Place a symbol (component) on a KiCad schematic. The symbol is identified by its KiCad library ID (e.g. ``Device:R``, ``power:GND``). Position is in KiCad schematic coordinate space where the origin is top-left. Args: schematic_path: Path to an existing .kicad_sch file. lib_id: KiCad library identifier such as ``Device:R`` or ``Connector:Conn_01x04``. reference: Reference designator (e.g. ``R1``, ``C3``, ``U2``). value: Component value string (e.g. ``10k``, ``100nF``, ``ATmega328P``). x: Horizontal position in schematic units. y: Vertical position in schematic units. Returns: Dictionary with ``success``, component ``reference``, and ``lib_id``. """ schematic_path = _expand(schematic_path) verr = _validate_schematic_path(schematic_path) if verr: return verr try: sch = load_schematic(schematic_path) sch.components.add( lib_id=lib_id, reference=reference, value=value, position=(x, y), ) sch.save(schematic_path) logger.info("Added %s (%s) to %s at (%.1f, %.1f)", reference, lib_id, schematic_path, x, y) return { "success": True, "reference": reference, "lib_id": lib_id, "value": value, "position": {"x": x, "y": y}, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to add component %s to %s: %s", reference, schematic_path, e) return { "success": False, "error": str(e), "schematic_path": schematic_path, } @mcp.tool() def search_components(query: str, library: str | None = None) -> dict[str, Any]: """Search KiCad symbol libraries for components matching a query. Useful for discovering available symbols before placing them with ``add_component``. Results include library IDs that can be passed directly to ``add_component``'s ``lib_id`` parameter. Args: query: Search term (e.g. ``resistor``, ``op amp``, ``STM32``). library: Optional library name to restrict the search (e.g. ``Device``, ``MCU_ST_STM32``). Searches all libraries when omitted. Returns: Dictionary with ``success`` and a ``results`` list of matching symbols. """ try: raw_results = _lr_search(query) # Filter by library when requested if library: raw_results = [r for r in raw_results if _matches_library(r, library)] results = [] for item in raw_results: entry: dict[str, Any] = {} if isinstance(item, dict): entry = item elif isinstance(item, str): entry = {"lib_id": item} else: # Object with attributes entry = { "lib_id": getattr(item, "lib_id", str(item)), "name": getattr(item, "name", None), "description": getattr(item, "description", None), "keywords": getattr(item, "keywords", None), } results.append(entry) logger.info("Symbol search for '%s' returned %d results", query, len(results)) # Large result -> sidecar file with top results inline if len(results) > INLINE_RESULT_THRESHOLD: safe_query = re.sub(r"[^\w\-]", "_", query)[:30] detail_path = write_detail_file(None, f"search_{safe_query}.json", results) return { "success": True, "query": query, "library": library, "count": len(results), "results": results[:10], "truncated": True, "detail_file": detail_path, "hint": f"Showing first 10 of {len(results)} results. Full list in detail_file.", "engine": _get_schematic_engine(), } return { "success": True, "query": query, "library": library, "count": len(results), "results": results, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Symbol search failed for '%s': %s", query, e) return {"success": False, "error": str(e), "query": query} @mcp.tool() def add_wire( schematic_path: str, start_x: float, start_y: float, end_x: float, end_y: float, ) -> dict[str, Any]: """Draw a wire segment between two points on a schematic. Wires create electrical connections between component pins, labels, and other wires. For connecting specific pins by reference designator, see ``connect_pins`` which handles coordinate lookup automatically. Args: schematic_path: Path to an existing .kicad_sch file. start_x: Starting X coordinate. start_y: Starting Y coordinate. end_x: Ending X coordinate. end_y: Ending Y coordinate. Returns: Dictionary with ``success`` and the wire ``id``. """ schematic_path = _expand(schematic_path) verr = _validate_schematic_path(schematic_path) if verr: return verr try: sch = load_schematic(schematic_path) wire = sch.add_wire(start=(start_x, start_y), end=(end_x, end_y)) sch.save(schematic_path) logger.info( "Added wire from (%.1f, %.1f) to (%.1f, %.1f) in %s", start_x, start_y, end_x, end_y, schematic_path, ) return { "success": True, "wire_id": str(wire.uuid), "start": {"x": start_x, "y": start_y}, "end": {"x": end_x, "y": end_y}, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to add wire in %s: %s", schematic_path, e) return {"success": False, "error": str(e), "schematic_path": schematic_path} @mcp.tool() def connect_pins( schematic_path: str, from_ref: str, from_pin: str, to_ref: str, to_pin: str, ) -> dict[str, Any]: """Wire two component pins together by reference designator and pin name. This is the high-level wiring tool -- it looks up pin positions from the component references and draws a wire between them. Prefer this over ``add_wire`` when you know the component references and pin identifiers. Args: schematic_path: Path to an existing .kicad_sch file. from_ref: Source component reference designator (e.g. ``R1``). from_pin: Pin identifier on the source component (e.g. ``1``, ``A``). to_ref: Destination component reference designator (e.g. ``R2``). to_pin: Pin identifier on the destination component (e.g. ``2``, ``K``). Returns: Dictionary with ``success`` and the created wire ``id``. """ schematic_path = _expand(schematic_path) verr = _validate_schematic_path(schematic_path) if verr: return verr try: sch = load_schematic(schematic_path) wire = sch.add_wire_between_pins(from_ref, from_pin, to_ref, to_pin) sch.save(schematic_path) logger.info( "Connected %s pin %s -> %s pin %s in %s", from_ref, from_pin, to_ref, to_pin, schematic_path, ) return { "success": True, "wire_id": str(wire.uuid), "from": {"reference": from_ref, "pin": from_pin}, "to": {"reference": to_ref, "pin": to_pin}, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error( "Failed to connect %s.%s -> %s.%s in %s: %s", from_ref, from_pin, to_ref, to_pin, schematic_path, e, ) return { "success": False, "error": str(e), "schematic_path": schematic_path, } @mcp.tool() def add_label( schematic_path: str, text: str, x: float | None = None, y: float | None = None, global_label: bool = False, rotation: float = 0, shape: str = "bidirectional", pin_ref: str | None = None, pin_number: str | None = None, stub_length: float = 2.54, ) -> dict[str, Any]: """Add a net label or global label to a schematic. Local labels connect nets within the same sheet. Global labels connect nets across hierarchical sheets -- use global labels for power rails, clock signals, and inter-sheet buses. Supports two placement modes: - **Coordinate**: provide ``x`` and ``y`` to place at an exact position. - **Pin-referenced**: provide ``pin_ref`` and ``pin_number`` to automatically resolve position and rotation from a component pin. A wire stub is inserted connecting the pin to the label. Args: schematic_path: Path to an existing .kicad_sch file. text: Label text (becomes the net name, e.g. ``GND``, ``SPI_CLK``). x: Horizontal position in schematic units (coordinate mode). y: Vertical position in schematic units (coordinate mode). global_label: When True, creates a global label visible across all hierarchical sheets. Defaults to a local label. rotation: Label rotation in degrees (0, 90, 180, 270). Ignored when using pin-referenced placement. shape: Global label shape (``bidirectional``, ``input``, ``output``, ``tri_state``, ``passive``). Ignored for local labels. pin_ref: Component reference for pin-referenced placement (e.g. ``U8``). pin_number: Pin number for pin-referenced placement (e.g. ``15``). stub_length: Wire stub length in schematic units (default 2.54). Only used with pin-referenced placement. Returns: Dictionary with ``success``, the ``label_id``, and label type. """ schematic_path = _expand(schematic_path) verr = _validate_schematic_path(schematic_path) if verr: return verr if not text: return {"success": False, "error": "Label text must be a non-empty string"} has_coords = x is not None and y is not None has_pin_ref = pin_ref is not None and pin_number is not None if not has_coords and not has_pin_ref: return { "success": False, "error": "Must provide either (x, y) coordinates or (pin_ref, pin_number)", } try: sch = load_schematic(schematic_path) if has_pin_ref: # Pin-referenced placement pin_info = resolve_pin_position_and_orientation( sch, schematic_path, pin_ref, pin_number, # type: ignore[arg-type] ) if pin_info is None: return { "success": False, "error": f"Pin {pin_ref}.{pin_number} not found in schematic", "schematic_path": schematic_path, } placement = compute_label_placement( pin_info["x"], pin_info["y"], pin_info["schematic_rotation"], stub_length=stub_length, ) lx, ly = placement["label_x"], placement["label_y"] label_rotation = placement["label_rotation"] if global_label: gl = sch.add_global_label( text, lx, ly, shape=shape, rotation=label_rotation, ) label_id = gl.uuid label_type = "global" else: lbl = sch.add_label(text, lx, ly, rotation=label_rotation) label_id = lbl.uuid label_type = "local" # Add wire stub sch.add_wire( placement["stub_start_x"], placement["stub_start_y"], placement["stub_end_x"], placement["stub_end_y"], ) x, y = lx, ly else: # Coordinate placement if global_label: gl = sch.add_global_label( text, x, y, shape=shape, rotation=rotation, # type: ignore[arg-type] ) label_id = gl.uuid label_type = "global" else: lbl = sch.add_label( text, x, y, rotation=rotation, # type: ignore[arg-type] ) label_id = lbl.uuid label_type = "local" sch.save(schematic_path) logger.info( "Added %s label '%s' at (%.1f, %.1f) in %s", label_type, text, x, y, schematic_path, ) return { "success": True, "label_id": label_id, "text": text, "label_type": label_type, "position": {"x": x, "y": y}, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to add label '%s' in %s: %s", text, schematic_path, e) return {"success": False, "error": str(e), "schematic_path": schematic_path} @mcp.tool() def add_hierarchical_sheet( schematic_path: str, name: str, filename: str, x: float, y: float, width: float, height: float, ) -> dict[str, Any]: """Add a hierarchical sub-sheet to a schematic. Hierarchical sheets let you break a design into logical blocks. The sub-sheet is represented as a rectangle on the parent schematic and references a separate .kicad_sch file for the child sheet's contents. Args: schematic_path: Path to the parent .kicad_sch file. name: Display name shown on the sheet symbol (e.g. ``Power Supply``). filename: Filename of the child schematic (e.g. ``power_supply.kicad_sch``). Will be resolved relative to the parent schematic's directory. x: Top-left X position of the sheet rectangle. y: Top-left Y position of the sheet rectangle. width: Width of the sheet rectangle in schematic units. height: Height of the sheet rectangle in schematic units. Returns: Dictionary with ``success`` and sheet metadata. """ schematic_path = _expand(schematic_path) verr = _validate_schematic_path(schematic_path) if verr: return verr if not filename.endswith(".kicad_sch"): return { "success": False, "error": f"Sheet filename must end with .kicad_sch, got: {filename}", } try: sch = load_schematic(schematic_path) sheet_result = sch.add_sheet( name=name, filename=filename, x=x, y=y, width=width, height=height, ) parent_uuid = sch.uuid sch.save(schematic_path) logger.info( "Added hierarchical sheet '%s' (%s) at (%.1f, %.1f) size %.1fx%.1f in %s", name, filename, x, y, width, height, schematic_path, ) return { "success": True, "sheet_name": name, "sheet_filename": filename, "sheet_uuid": sheet_result["uuid"], "parent_uuid": parent_uuid, "position": {"x": x, "y": y}, "size": {"width": width, "height": height}, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to add sheet '%s' in %s: %s", name, schematic_path, e) return {"success": False, "error": str(e), "schematic_path": schematic_path} @mcp.tool() def list_components( schematic_path: str, reference: str | None = None, ) -> dict[str, Any]: """List components in a KiCad schematic, or look up a single component. When called without ``reference``, returns all components. For large schematics (>{threshold} components), a compact summary is returned inline and the full list is written to ``.mckicad/components.json`` -- read that file for complete data. When ``reference`` is provided, returns only that component's details (always inline, always compact). Args: schematic_path: Path to a .kicad_sch file. reference: Optional reference designator to look up a single component (e.g. ``U1``, ``R42``). Returns: Dictionary with ``success``, ``count``, and either inline ``components`` list or a ``detail_file`` path. """.replace("{threshold}", str(INLINE_RESULT_THRESHOLD)) schematic_path = _expand(schematic_path) verr = _validate_schematic_path(schematic_path) if verr: return verr try: sch = load_schematic(schematic_path) components: list[dict[str, Any]] = [] for comp in sch.components: entry: dict[str, Any] = { "reference": comp.reference, "lib_id": comp.lib_id, "value": comp.value, "position": {"x": comp.position.x, "y": comp.position.y}, } components.append(entry) # Single-component lookup if reference: match = [c for c in components if c.get("reference") == reference] if not match: return { "success": False, "error": f"Component '{reference}' not found in schematic", "schematic_path": schematic_path, } return { "success": True, "count": 1, "components": match, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } logger.info("Listed %d components in %s", len(components), schematic_path) # Large result -> sidecar file if len(components) > INLINE_RESULT_THRESHOLD: prefix_counts = Counter( re.match(r"[A-Za-z]+", c.get("reference", "") or "").group() # type: ignore[union-attr] for c in components if c.get("reference") and re.match(r"[A-Za-z]+", c.get("reference", "")) ) detail_path = write_detail_file(schematic_path, "components.json", components) return { "success": True, "count": len(components), "breakdown": dict(prefix_counts), "detail_file": detail_path, "hint": "Full component list written to detail_file. Read it for complete data.", "schematic_path": schematic_path, "engine": _get_schematic_engine(), } return { "success": True, "count": len(components), "components": components, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to list components in %s: %s", schematic_path, e) return {"success": False, "error": str(e), "schematic_path": schematic_path} @mcp.tool() def get_schematic_info(schematic_path: str) -> dict[str, Any]: """Get a compact overview of a KiCad schematic. Returns statistics and a validation summary inline. For schematics with many unique symbols, full symbol details and validation issues are written to ``.mckicad/schematic_info.json`` -- read that file when you need per-symbol data. Args: schematic_path: Path to a .kicad_sch file. Returns: Dictionary with ``success``, ``statistics``, ``validation`` summary, ``unique_symbol_count``, and optionally a ``detail_file`` path. """ schematic_path = _expand(schematic_path) verr = _validate_schematic_path(schematic_path) if verr: return verr try: sch = load_schematic(schematic_path) # Gather statistics stats = sch.get_statistics() # Supplement text_elements for backwards compatibility stats["text_elements"] = { "label": stats.get("labels", 0), "global_label": stats.get("global_labels", 0), "total_text_elements": stats.get("labels", 0) + stats.get("global_labels", 0), } # Run validation issues = sch.validate() # Collect symbol details lib_ids_seen: set[str] = set() symbol_details: list[dict[str, Any]] = [] for comp in sch.components: lid = comp.lib_id if lid and lid not in lib_ids_seen: lib_ids_seen.add(lid) try: info = _lr_get_info(lid) if info is not None: symbol_details.append(info) else: symbol_details.append({"lib_id": lid, "lookup_failed": True}) except Exception: symbol_details.append({"lib_id": lid, "lookup_failed": True}) validation_passed = len(issues) == 0 logger.info("Retrieved info for %s: %d issues", schematic_path, len(issues)) result: dict[str, Any] = { "success": True, "schematic_path": schematic_path, "statistics": stats, "validation": { "passed": validation_passed, "issue_count": len(issues), }, "unique_symbol_count": len(symbol_details), "engine": _get_schematic_engine(), } # Write large detail data to sidecar file if len(symbol_details) > INLINE_RESULT_THRESHOLD or len(issues) > INLINE_RESULT_THRESHOLD: detail_data = { "symbol_details": symbol_details, "validation_issues": issues, } result["detail_file"] = write_detail_file( schematic_path, "schematic_info.json", detail_data ) result["hint"] = ( "Symbol details and validation issues written to detail_file. " "Read it for per-symbol data." ) else: # Small enough to return inline result["validation"]["issues"] = issues result["symbol_details"] = symbol_details return result except Exception as e: logger.error("Failed to get schematic info for %s: %s", schematic_path, e) return {"success": False, "error": str(e), "schematic_path": schematic_path} @mcp.tool() def get_component_detail(schematic_path: str, reference: str) -> dict[str, Any]: """Get full details for a single component: properties, footprint, pins, position. Always returns a compact inline result (single component). Use this for deep inspection after ``list_components`` identifies a component of interest. Args: schematic_path: Path to a .kicad_sch file. reference: Reference designator (e.g. ``U1``, ``R42``). Returns: Dictionary with ``success`` and detailed component data including properties, pin list, footprint, and validation results. """ schematic_path = _expand(schematic_path) verr = _validate_schematic_path(schematic_path) if verr: return verr try: sch = load_schematic(schematic_path) comp = sch.components.get(reference) if comp is None: return { "success": False, "error": f"Component '{reference}' not found in schematic", "schematic_path": schematic_path, } detail: dict[str, Any] = { "reference": reference, "lib_id": comp.lib_id, "value": comp.value, "footprint": comp.footprint, "position": {"x": comp.position.x, "y": comp.position.y}, "rotation": comp.rotation, } # Properties detail["properties"] = comp.properties # Pins via SchDocument.list_component_pins() pins: list[dict[str, Any]] = [] pin_data = sch.list_component_pins(reference) for pin_num, pos in pin_data: pins.append({ "number": pin_num, "position": {"x": pos.x, "y": pos.y}, }) # Enrich pins with name/type from lib_resolver if available lib_info = _lr_get_info(comp.lib_id) if lib_info and "pins" in lib_info: lib_pins_by_num = { str(p["number"]): p for p in lib_info["pins"] } for pin in pins: lib_pin = lib_pins_by_num.get(str(pin["number"])) if lib_pin: pin["name"] = lib_pin.get("name") pin["type"] = lib_pin.get("type") detail["pins"] = pins detail["pin_count"] = len(pins) # BOM/board flags detail["in_bom"] = comp.in_bom detail["on_board"] = comp.on_board logger.info("Got detail for %s in %s", reference, schematic_path) return { "success": True, "component": detail, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to get detail for %s in %s: %s", reference, schematic_path, e) return {"success": False, "error": str(e), "schematic_path": schematic_path} @mcp.tool() def get_schematic_hierarchy(schematic_path: str) -> dict[str, Any]: """Get the hierarchical sheet tree of a KiCad schematic. Returns the sheet structure with filenames and per-sheet component counts. Essential for multi-sheet designs -- use this to understand the design's organization before diving into specific sheets. Args: schematic_path: Path to the root .kicad_sch file. Returns: Dictionary with ``success`` and a ``hierarchy`` tree of sheets. """ schematic_path = _expand(schematic_path) verr = _validate_schematic_path(schematic_path) if verr: return verr try: sch = load_schematic(schematic_path) schematic_dir = os.path.dirname(schematic_path) def _count_components(filepath: str) -> int: """Load a schematic and count its components. Returns -1 on failure.""" if not os.path.isfile(filepath): return -1 try: sheet_sch = load_schematic(filepath) return len(sheet_sch.components) except Exception: return -1 def _walk_sheets(sheets: list, base_dir: str) -> list[dict[str, Any]]: """Recursively walk sheets to build the hierarchy tree.""" result_sheets: list[dict[str, Any]] = [] for sheet in sheets: filename = sheet.filename info: dict[str, Any] = { "name": sheet.name, "filename": filename, } if filename: filepath = os.path.join(base_dir, filename) count = _count_components(filepath) if count >= 0: info["component_count"] = count else: info["component_count"] = 0 info["note"] = "file not found or failed to load" # Recurse into child sheets if os.path.isfile(filepath): with contextlib.suppress(Exception): child_sch = load_schematic(filepath) if child_sch.sheets: child_dir = os.path.dirname(filepath) info["sheets"] = _walk_sheets(child_sch.sheets, child_dir) else: info["component_count"] = 0 # Count total sheets child_sheets = info.get("sheets", []) if child_sheets: info["total_sheets"] = 1 + sum( c.get("total_sheets", 1) for c in child_sheets ) else: info["total_sheets"] = 1 result_sheets.append(info) return result_sheets root_info: dict[str, Any] = { "name": "root", "filename": os.path.basename(schematic_path), "component_count": len(sch.components), } if sch.sheets: root_info["sheets"] = _walk_sheets(sch.sheets, schematic_dir) root_info["total_sheets"] = 1 + sum( c.get("total_sheets", 1) for c in root_info["sheets"] ) else: root_info["total_sheets"] = 1 hierarchy = root_info logger.info("Got hierarchy for %s: %d total sheets", schematic_path, hierarchy["total_sheets"]) return { "success": True, "hierarchy": hierarchy, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to get hierarchy for %s: %s", schematic_path, e) return {"success": False, "error": str(e), "schematic_path": schematic_path} # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _matches_library(item: Any, library: str) -> bool: """Check whether a search result belongs to the given library.""" lib_id = None if isinstance(item, dict): lib_id = item.get("lib_id", "") elif isinstance(item, str): lib_id = item else: lib_id = getattr(item, "lib_id", str(item)) if not lib_id: return False # lib_id format is "Library:Symbol" -- match on the library portion if ":" in lib_id: return lib_id.split(":")[0].lower() == library.lower() return library.lower() in str(lib_id).lower()