Initial project structure for spice2wireviz

SPICE netlist to WireViz YAML converter with:
- Custom lightweight netlist parser (.net/.cir/.sp)
- Single-module mapper (subcircuit external interface)
- Inter-module mapper (multi-board wiring)
- Filter engine with glob patterns
- Click CLI with auto-detection, inspection commands
- Optional .asc parser via spicelib
- Comprehensive test suite with fixtures
This commit is contained in:
Ryan Malloy 2026-02-13 01:24:41 -07:00
commit e20a956f51
24 changed files with 2630 additions and 0 deletions

37
pyproject.toml Normal file
View File

@ -0,0 +1,37 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "spice2wireviz"
version = "2026-02-13"
description = "Convert LTspice SPICE netlists to WireViz wiring diagrams"
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
requires-python = ">=3.11"
license = {text = "MIT"}
readme = "README.md"
dependencies = [
"pyyaml>=6.0",
"click>=8.0",
"pydantic>=2.0",
]
[project.optional-dependencies]
asc = ["spicelib>=1.4.9"]
dev = ["ruff", "pytest", "pytest-cov"]
[project.scripts]
spice2wireviz = "spice2wireviz.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/spice2wireviz"]
[tool.ruff]
target-version = "py311"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "W", "UP", "B", "SIM", "RUF"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View File

@ -0,0 +1,3 @@
"""spice2wireviz — Convert LTspice SPICE netlists to WireViz wiring diagrams."""
__version__ = "2026-02-13"

313
src/spice2wireviz/cli.py Normal file
View File

@ -0,0 +1,313 @@
"""Click CLI for spice2wireviz.
Converts SPICE netlists to WireViz YAML wiring diagrams.
"""
from __future__ import annotations
import sys
from pathlib import Path
import click
from . import __version__
from .emitter.yaml_emitter import emit_yaml
from .filter import FilterConfig, apply_filters
from .mapper.inter_module import map_inter_module
from .mapper.single_module import map_single_module
from .parser.netlist import parse_netlist
def _parse_comma_list(ctx: click.Context, param: click.Parameter, value: str | None) -> list[str]:
"""Click callback to parse comma-separated values."""
if not value:
return []
return [v.strip() for v in value.split(",") if v.strip()]
@click.command()
@click.argument("input_file", type=click.Path(exists=True, path_type=Path))
@click.option(
"-m",
"--mode",
type=click.Choice(["single", "inter"], case_sensitive=False),
default=None,
help="Operating mode (auto-detect if omitted).",
)
@click.option(
"-s",
"--subcircuit",
default=None,
help="Subcircuit name for single-module mode.",
)
@click.option(
"--include-prefixes",
default=None,
callback=_parse_comma_list,
help="Component prefixes to include (default: J,TP,P,X).",
)
@click.option(
"--exclude-prefixes",
default=None,
callback=_parse_comma_list,
help="Component prefixes to exclude.",
)
@click.option(
"--include-refs",
default=None,
callback=_parse_comma_list,
help="Specific references to include.",
)
@click.option(
"--exclude-refs",
default=None,
callback=_parse_comma_list,
help="Specific references to exclude.",
)
@click.option(
"--include-nets",
default=None,
callback=_parse_comma_list,
help="Net name glob patterns to include.",
)
@click.option(
"--exclude-nets",
default=None,
callback=_parse_comma_list,
help="Net name glob patterns to exclude.",
)
@click.option(
"--include-subcircuits",
default=None,
callback=_parse_comma_list,
help="Subcircuit names to include.",
)
@click.option(
"--exclude-subcircuits",
default=None,
callback=_parse_comma_list,
help="Subcircuit names to exclude.",
)
@click.option("--no-ground", is_flag=True, help="Hide GND connections.")
@click.option("--no-power", is_flag=True, help="Hide power connections.")
@click.option(
"--no-group",
is_flag=True,
help="Don't group parallel wires into multi-wire cables.",
)
@click.option(
"-o",
"--output",
type=click.Path(path_type=Path),
default=None,
help="Output YAML file (default: stdout).",
)
@click.option(
"--render",
is_flag=True,
help="Also run WireViz to generate diagram.",
)
@click.option(
"--format",
"output_formats",
default="hps",
help="WireViz output format flags with --render (h=html, p=png, s=svg, default: hps).",
)
@click.option(
"--list-subcircuits",
is_flag=True,
help="List all .subckt definitions and exit.",
)
@click.option(
"--list-components",
is_flag=True,
help="List all components matching filters and exit.",
)
@click.option("--dry-run", is_flag=True, help="Show mapping summary without generating YAML.")
@click.version_option(version=__version__)
def main(
input_file: Path,
mode: str | None,
subcircuit: str | None,
include_prefixes: list[str],
exclude_prefixes: list[str],
include_refs: list[str],
exclude_refs: list[str],
include_nets: list[str],
exclude_nets: list[str],
include_subcircuits: list[str],
exclude_subcircuits: list[str],
no_ground: bool,
no_power: bool,
no_group: bool,
output: Path | None,
render: bool,
output_formats: str,
list_subcircuits: bool,
list_components: bool,
dry_run: bool,
) -> None:
"""Convert SPICE netlist to WireViz YAML wiring diagram."""
# Parse the netlist
try:
netlist = parse_netlist(input_file)
except FileNotFoundError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
# --- Inspection commands ---
if list_subcircuits:
names = netlist.list_subcircuit_names()
if not names:
click.echo("No subcircuit definitions found.")
else:
for name in names:
subckt = netlist.subcircuit_defs[name]
ports = ", ".join(subckt.port_names)
boundary_count = len(subckt.boundary_components)
click.echo(f" {name}: ports=[{ports}], boundary_components={boundary_count}")
return
# Build filter config
filter_config = FilterConfig(
include_prefixes=include_prefixes or ["J", "TP", "P", "X"],
exclude_prefixes=exclude_prefixes,
include_refs=include_refs,
exclude_refs=exclude_refs,
include_nets=include_nets,
exclude_nets=exclude_nets,
include_subcircuits=include_subcircuits,
exclude_subcircuits=exclude_subcircuits,
show_ground=not no_ground,
show_power=not no_power,
group_parallel_wires=not no_group,
)
if list_components:
filtered = apply_filters(netlist, filter_config)
if filtered.top_level_components:
click.echo("Top-level components:")
for comp in filtered.top_level_components:
click.echo(f" {comp.reference} ({comp.prefix}): nodes={comp.nodes}")
if filtered.instances:
click.echo("Subcircuit instances:")
for inst in filtered.instances:
nets = ", ".join(f"{k}={v}" for k, v in inst.port_to_net.items())
click.echo(f" {inst.reference} ({inst.subcircuit_name}): {nets}")
return
# --- Auto-detect mode ---
if mode is None:
if subcircuit:
mode = "single"
elif netlist.instances:
mode = "inter"
elif netlist.subcircuit_defs:
# Has subcircuit defs but no top-level instances — default to first subcircuit
mode = "single"
subcircuit = netlist.list_subcircuit_names()[0]
click.echo(
f"Auto-detected single-module mode for subcircuit '{subcircuit}'",
err=True,
)
else:
click.echo(
"Error: no subcircuit instances or definitions found. "
"Use --mode and --subcircuit to specify explicitly.",
err=True,
)
sys.exit(1)
# --- Build metadata ---
meta = {
"title": f"Wiring diagram: {input_file.stem}",
"source": str(input_file),
"generator": f"spice2wireviz {__version__}",
}
# --- Map to WireViz ---
if mode == "single":
if not subcircuit:
names = netlist.list_subcircuit_names()
if len(names) == 1:
subcircuit = names[0]
else:
click.echo(
"Error: single-module mode requires --subcircuit. "
f"Available: {', '.join(names)}",
err=True,
)
sys.exit(1)
try:
wireviz_dict = map_single_module(netlist, subcircuit, filter_config, meta)
except ValueError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
else:
wireviz_dict = map_inter_module(netlist, filter_config, meta)
# --- Dry run ---
if dry_run:
conn_count = len(wireviz_dict.get("connectors", {}))
cable_count = len(wireviz_dict.get("cables", {}))
conn_set_count = len(wireviz_dict.get("connections", []))
click.echo(f"Mode: {mode}")
click.echo(f"Connectors: {conn_count}")
click.echo(f"Cables: {cable_count}")
click.echo(f"Connection sets: {conn_set_count}")
if wireviz_dict.get("connectors"):
click.echo("Connector names: " + ", ".join(wireviz_dict["connectors"].keys()))
return
# --- Emit YAML ---
yaml_str = emit_yaml(wireviz_dict)
if output:
output.write_text(yaml_str, encoding="utf-8")
click.echo(f"Wrote {output}", err=True)
else:
click.echo(yaml_str)
# --- Optional render ---
if render:
_render_wireviz(wireviz_dict, output, output_formats)
def _render_wireviz(
wireviz_dict: dict, output: Path | None, format_flags: str
) -> None:
"""Invoke WireViz to render the diagram."""
try:
from wireviz.wireviz import parse as wv_parse
except ImportError:
click.echo(
"Error: WireViz not installed. Install it with: pip install wireviz",
err=True,
)
sys.exit(1)
# Map format flags to WireViz output_formats
format_map = {"h": "html", "p": "png", "s": "svg", "g": "gv", "c": "csv", "t": "tsv"}
formats = tuple(format_map[f] for f in format_flags.lower() if f in format_map)
if not formats:
formats = ("html", "png", "svg")
output_dir = output.parent if output else Path.cwd()
output_name = output.stem if output else "spice2wireviz_output"
try:
wv_parse(
wireviz_dict,
output_formats=formats,
output_dir=str(output_dir),
output_name=output_name,
)
click.echo(
f"Rendered: {', '.join(f'{output_name}.{fmt}' for fmt in formats)}",
err=True,
)
except Exception as exc:
click.echo(f"WireViz render error: {exc}", err=True)
sys.exit(1)

View File

View File

@ -0,0 +1,166 @@
"""WireViz YAML generation from mapped connector/cable/connection data.
Produces deterministic YAML output: sorted keys, stable ordering,
byte-identical output for identical input. Every generated element
includes traceability notes with the source SPICE reference and net.
"""
from __future__ import annotations
from collections import OrderedDict
from typing import Any
import yaml
class _OrderedDumper(yaml.SafeDumper):
"""YAML dumper that preserves dict insertion order and uses block style."""
pass
def _dict_representer(dumper: yaml.Dumper, data: dict) -> yaml.Node:
return dumper.represent_mapping(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, data.items())
_OrderedDumper.add_representer(dict, _dict_representer)
_OrderedDumper.add_representer(OrderedDict, _dict_representer)
def emit_yaml(wireviz_dict: dict[str, Any]) -> str:
"""Serialize a WireViz dict to deterministic YAML string.
Args:
wireviz_dict: Dict with keys like 'connectors', 'cables', 'connections', etc.
Returns:
YAML string ready to write to file or feed to wireviz.parse().
"""
return yaml.dump(
wireviz_dict,
Dumper=_OrderedDumper,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
width=120,
)
def build_connector(
name: str,
*,
pinlabels: list[str] | None = None,
pincount: int | None = None,
pins: list[int] | None = None,
connector_type: str = "",
subtype: str = "",
notes: str = "",
style: str = "",
color: str = "",
) -> dict[str, Any]:
"""Build a WireViz connector definition dict.
At least one of pinlabels, pincount, or pins must be provided.
"""
conn: dict[str, Any] = {}
if connector_type:
conn["type"] = connector_type
if subtype:
conn["subtype"] = subtype
if color:
conn["color"] = color
if style:
conn["style"] = style
if pinlabels:
conn["pinlabels"] = pinlabels
elif pins:
conn["pins"] = pins
elif pincount:
conn["pincount"] = pincount
if notes:
conn["notes"] = notes
return conn
def build_cable(
name: str,
*,
wirecount: int | None = None,
colors: list[str] | None = None,
wirelabels: list[str] | None = None,
cable_type: str = "",
category: str = "",
notes: str = "",
) -> dict[str, Any]:
"""Build a WireViz cable definition dict."""
cable: dict[str, Any] = {}
if category:
cable["category"] = category
if cable_type:
cable["type"] = cable_type
if colors:
cable["colors"] = colors
elif wirecount:
cable["wirecount"] = wirecount
if wirelabels:
cable["wirelabels"] = wirelabels
if notes:
cable["notes"] = notes
return cable
def build_connection(
from_connector: str,
from_pins: list[int],
cable_name: str,
cable_wires: list[int],
to_connector: str,
to_pins: list[int],
) -> list[dict[str, Any]]:
"""Build a WireViz connection set (one entry in the connections list).
Returns a list of dicts representing [connector:pins, cable:wires, connector:pins].
"""
conn_set: list[dict[str, Any]] = []
# From connector
if len(from_pins) == 1:
conn_set.append({from_connector: from_pins[0]})
else:
conn_set.append({from_connector: from_pins})
# Cable
if len(cable_wires) == 1:
conn_set.append({cable_name: cable_wires[0]})
else:
conn_set.append({cable_name: cable_wires})
# To connector
if len(to_pins) == 1:
conn_set.append({to_connector: to_pins[0]})
else:
conn_set.append({to_connector: to_pins})
return conn_set
def assemble_wireviz_doc(
connectors: dict[str, dict[str, Any]],
cables: dict[str, dict[str, Any]],
connections: list[list[dict[str, Any]]],
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Assemble the full WireViz document dict."""
doc: dict[str, Any] = {}
if metadata:
doc["metadata"] = metadata
doc["connectors"] = connectors
doc["cables"] = cables
doc["connections"] = connections
return doc

164
src/spice2wireviz/filter.py Normal file
View File

@ -0,0 +1,164 @@
"""Filter configuration and matching engine for spice2wireviz.
Allows users to cherry-pick which components, nets, and subcircuits
appear in the generated wiring diagram. Uses fnmatch for glob patterns.
"""
from __future__ import annotations
import sys
from fnmatch import fnmatch
from pydantic import BaseModel, Field
from .parser.models import ParsedNetlist, SpiceComponent, SubcircuitInstance
class FilterConfig(BaseModel):
"""Configuration for filtering netlist elements."""
include_prefixes: list[str] = Field(
default_factory=lambda: ["J", "TP", "P", "X"],
description="Component prefixes to include",
)
exclude_prefixes: list[str] = Field(
default_factory=list, description="Component prefixes to exclude"
)
include_refs: list[str] = Field(
default_factory=list, description="Specific references to include (empty = all)"
)
exclude_refs: list[str] = Field(
default_factory=list, description="Specific references to exclude"
)
include_nets: list[str] = Field(
default_factory=list, description="Net name glob patterns to include (empty = all)"
)
exclude_nets: list[str] = Field(
default_factory=list, description="Net name glob patterns to exclude"
)
include_subcircuits: list[str] = Field(
default_factory=list, description="Subcircuit names to include (empty = all)"
)
exclude_subcircuits: list[str] = Field(
default_factory=list, description="Subcircuit names to exclude"
)
show_ground: bool = True
show_power: bool = True
group_parallel_wires: bool = True
def _matches_any_glob(value: str, patterns: list[str]) -> bool:
"""Check if value matches any of the glob patterns."""
return any(fnmatch(value, pat) for pat in patterns)
def filter_component(comp: SpiceComponent, config: FilterConfig, netlist: ParsedNetlist) -> bool:
"""Determine whether a component should be included in the output.
Returns True if the component passes all filters.
"""
prefix = comp.prefix.upper()
# Prefix filtering
if config.exclude_prefixes and prefix in {p.upper() for p in config.exclude_prefixes}:
return False
if config.include_prefixes and prefix not in {p.upper() for p in config.include_prefixes}:
return False
# Specific reference filtering
if config.exclude_refs and comp.reference in config.exclude_refs:
return False
if config.include_refs and comp.reference not in config.include_refs:
return False
return True
def filter_instance(
inst: SubcircuitInstance, config: FilterConfig, netlist: ParsedNetlist
) -> bool:
"""Determine whether a subcircuit instance should be included."""
# Prefix filtering (X prefix)
if config.exclude_prefixes and "X" in {p.upper() for p in config.exclude_prefixes}:
return False
if config.include_prefixes and "X" not in {p.upper() for p in config.include_prefixes}:
return False
# Reference filtering
if config.exclude_refs and inst.reference in config.exclude_refs:
return False
if config.include_refs and inst.reference not in config.include_refs:
return False
# Subcircuit name filtering
if config.exclude_subcircuits and inst.subcircuit_name in config.exclude_subcircuits:
return False
if config.include_subcircuits and inst.subcircuit_name not in config.include_subcircuits:
return False
return True
def filter_net(net: str, config: FilterConfig, netlist: ParsedNetlist) -> bool:
"""Determine whether a net should be included in connections.
Warns on stderr when ground/power nets are filtered out.
"""
# Ground filtering
if not config.show_ground and netlist.is_ground_net(net):
return False
# Power filtering
if not config.show_power and netlist.is_power_net(net):
return False
# Glob pattern filtering
if config.exclude_nets and _matches_any_glob(net, config.exclude_nets):
return False
if config.include_nets and not _matches_any_glob(net, config.include_nets):
# Warn if we're excluding power/ground via glob patterns
if netlist.is_ground_net(net):
print(f"Warning: ground net '{net}' excluded by --include-nets filter", file=sys.stderr)
elif netlist.is_power_net(net):
print(f"Warning: power net '{net}' excluded by --include-nets filter", file=sys.stderr)
return False
return True
def apply_filters(netlist: ParsedNetlist, config: FilterConfig) -> ParsedNetlist:
"""Return a new ParsedNetlist with filters applied.
Produces warnings on stderr for significant exclusions.
"""
filtered_components = [
comp
for comp in netlist.top_level_components
if filter_component(comp, config, netlist)
]
filtered_instances = [
inst for inst in netlist.instances if filter_instance(inst, config, netlist)
]
# Filter nets within surviving components/instances
excluded_net_count = 0
for comp in filtered_components:
original_count = len(comp.nodes)
comp = comp.model_copy()
new_nodes = [n for n in comp.nodes if filter_net(n, config, netlist)]
excluded_net_count += original_count - len(new_nodes)
if excluded_net_count > 0:
print(
f"Note: {excluded_net_count} net connections hidden by filters",
file=sys.stderr,
)
return ParsedNetlist(
subcircuit_defs=netlist.subcircuit_defs,
instances=filtered_instances,
top_level_components=filtered_components,
all_nets=netlist.all_nets,
global_nets=netlist.global_nets,
)

View File

View File

@ -0,0 +1,183 @@
"""Inter-module mapper: multi-subcircuit wiring to WireViz.
Given a top-level netlist with X* instances (subcircuit instantiations),
creates one connector per module and traces shared nets between them.
Parallel wires between the same pair of modules are grouped into
multi-wire cables for cleaner diagrams.
Also includes top-level J*/TP*/P* connectors directly.
"""
from __future__ import annotations
from collections import defaultdict
from typing import Any
from ..emitter.yaml_emitter import assemble_wireviz_doc, build_cable, build_connection, build_connector
from ..filter import FilterConfig, filter_component, filter_instance, filter_net
from ..parser.models import ParsedNetlist, SubcircuitInstance
def _net_wire_color(net: str, netlist: ParsedNetlist) -> str:
if netlist.is_ground_net(net):
return "BK"
if netlist.is_power_net(net):
return "RD"
return ""
def _make_connector_name(ref: str) -> str:
"""Sanitize a reference for use as a WireViz connector name."""
return ref.replace(" ", "_")
def map_inter_module(
netlist: ParsedNetlist,
config: FilterConfig | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Map inter-module wiring to a WireViz document dict.
Creates connectors for each subcircuit instance and top-level boundary
component, then traces shared nets to create cables between them.
Args:
netlist: Parsed SPICE netlist.
config: Optional filter configuration.
metadata: Optional WireViz metadata dict.
Returns:
WireViz document dict ready for YAML emission.
"""
if config is None:
config = FilterConfig()
connectors: dict[str, dict[str, Any]] = {}
cables: dict[str, dict[str, Any]] = {}
connections: list[list[dict[str, Any]]] = []
# Build a map of net -> [(connector_name, pin_index)]
# This tracks every endpoint connected to each net
net_endpoints: dict[str, list[tuple[str, int, str]]] = defaultdict(list)
# --- Create connectors for X* instances ---
filtered_instances = [
inst for inst in netlist.instances if filter_instance(inst, config, netlist)
]
for inst in filtered_instances:
conn_name = _make_connector_name(inst.reference)
port_labels = list(inst.port_to_net.keys())
filtered_ports: list[str] = []
filtered_nets: list[str] = []
for port_name, net in inst.port_to_net.items():
if filter_net(net, config, netlist):
filtered_ports.append(port_name)
filtered_nets.append(net)
if not filtered_ports:
continue
connectors[conn_name] = build_connector(
conn_name,
pinlabels=filtered_ports,
connector_type=inst.subcircuit_name,
notes=f"SPICE instance: {inst.reference} ({inst.subcircuit_name})",
)
# Register net endpoints
for pin_idx, (port, net) in enumerate(zip(filtered_ports, filtered_nets), 1):
net_endpoints[net].append((conn_name, pin_idx, port))
# --- Create connectors for top-level J*/TP*/P* ---
filtered_components = [
comp
for comp in netlist.top_level_components
if filter_component(comp, config, netlist)
]
for comp in filtered_components:
conn_name = _make_connector_name(comp.reference)
pin_labels = [p.name for p in comp.pins]
style = "simple" if comp.prefix.upper() == "TP" and len(pin_labels) == 1 else ""
connectors[conn_name] = build_connector(
conn_name,
pinlabels=pin_labels if pin_labels else None,
pincount=len(comp.nodes) if not pin_labels else None,
connector_type=comp.value or comp.prefix,
style=style,
notes=f"SPICE ref: {comp.reference}",
)
for pin_idx, node in enumerate(comp.nodes, 1):
if filter_net(node, config, netlist):
net_endpoints[node].append((conn_name, pin_idx, node))
# --- Trace connections via shared nets ---
# Group connections by (connector_a, connector_b) pair for parallel wire grouping
pair_wires: dict[tuple[str, str], list[tuple[int, int, str]]] = defaultdict(list)
for net, endpoints in sorted(net_endpoints.items()):
if len(endpoints) < 2:
continue
# Connect all pairs (for nets shared by >2 endpoints, use star topology from first)
anchor = endpoints[0]
for other in endpoints[1:]:
# Ensure consistent ordering for the pair key
a, b = anchor, other
if a[0] > b[0]:
a, b = b, a
pair_wires[(a[0], b[0])].append((a[1], b[1], net))
# --- Generate cables and connections ---
cable_counter = 0
for (conn_a, conn_b), wires in sorted(pair_wires.items()):
cable_counter += 1
if config.group_parallel_wires and len(wires) > 1:
# Multi-wire cable
cable_name = f"W{cable_counter}"
wire_colors = [_net_wire_color(net, netlist) for _, _, net in wires]
wire_labels = [net for _, _, net in wires]
cables[cable_name] = build_cable(
cable_name,
wirecount=len(wires),
colors=wire_colors if any(wire_colors) else None,
wirelabels=wire_labels,
category="bundle",
notes=f"Nets: {', '.join(wire_labels)}",
)
a_pins = [w[0] for w in wires]
b_pins = [w[1] for w in wires]
cable_wires = list(range(1, len(wires) + 1))
connections.append(
build_connection(conn_a, a_pins, cable_name, cable_wires, conn_b, b_pins)
)
else:
# Individual wires (one cable per wire)
for a_pin, b_pin, net in wires:
cable_counter_inner = cable_counter
cable_name = f"W{cable_counter}"
wire_color = _net_wire_color(net, netlist)
cables[cable_name] = build_cable(
cable_name,
wirecount=1,
colors=[wire_color] if wire_color else None,
wirelabels=[net],
notes=f"Net: {net}",
)
connections.append(
build_connection(conn_a, [a_pin], cable_name, [1], conn_b, [b_pin])
)
cable_counter += 1
return assemble_wireviz_doc(connectors, cables, connections, metadata)

View File

@ -0,0 +1,148 @@
"""Single-module mapper: subcircuit external interface to WireViz.
Given a subcircuit name, extracts its boundary components (J*, TP*, P*
defined inside it) and port interface. Generates:
- One "module header" connector representing the subcircuit's ports
- One connector per boundary component (J*, TP*, P*)
- Cables connecting the module header to boundary components via shared nets
This shows the external interface of a single board/module.
"""
from __future__ import annotations
from typing import Any
from ..emitter.yaml_emitter import assemble_wireviz_doc, build_cable, build_connection, build_connector
from ..filter import FilterConfig, filter_component, filter_net
from ..parser.models import ParsedNetlist, SpiceComponent, SubcircuitDef
def _net_wire_color(net: str, netlist: ParsedNetlist) -> str:
"""Assign a wire color based on net type."""
if netlist.is_ground_net(net):
return "BK"
if netlist.is_power_net(net):
return "RD"
return ""
def map_single_module(
netlist: ParsedNetlist,
subcircuit_name: str,
config: FilterConfig | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Map a subcircuit's external interface to a WireViz document dict.
Args:
netlist: Parsed SPICE netlist.
subcircuit_name: Name of the .subckt to map.
config: Optional filter configuration.
metadata: Optional WireViz metadata dict.
Returns:
WireViz document dict ready for YAML emission.
Raises:
ValueError: If subcircuit not found in netlist.
"""
if config is None:
config = FilterConfig()
subckt = netlist.get_subcircuit(subcircuit_name)
if subckt is None:
available = ", ".join(netlist.list_subcircuit_names()) or "(none)"
raise ValueError(
f"Subcircuit '{subcircuit_name}' not found. Available: {available}"
)
connectors: dict[str, dict[str, Any]] = {}
cables: dict[str, dict[str, Any]] = {}
connections: list[list[dict[str, Any]]] = []
# Module header connector: represents the subcircuit's port interface
header_name = subcircuit_name
port_labels = [p for p in subckt.port_names if filter_net(p, config, netlist)]
if port_labels:
connectors[header_name] = build_connector(
header_name,
pinlabels=port_labels,
connector_type="Module Interface",
notes=f"SPICE subcircuit: .subckt {subcircuit_name}",
)
# Boundary components (J*, TP*, P* inside this subcircuit)
boundary = [
comp
for comp in subckt.boundary_components
if filter_component(comp, config, netlist)
]
for comp in boundary:
comp_name = comp.reference
pin_labels = [p.name for p in comp.pins]
style = "simple" if comp.prefix.upper() == "TP" and len(pin_labels) == 1 else ""
connectors[comp_name] = build_connector(
comp_name,
pinlabels=pin_labels if pin_labels else None,
pincount=len(comp.nodes) if not pin_labels else None,
connector_type=comp.value or comp.prefix,
style=style,
notes=f"SPICE ref: {comp.reference}, nets: {', '.join(comp.nodes)}",
)
# Find shared nets between this component and the subcircuit ports
shared_nets = _find_shared_nets(subckt, comp, config, netlist)
if shared_nets and header_name in connectors:
cable_name = f"W_{comp_name}"
wire_colors = [_net_wire_color(net, netlist) for net in shared_nets]
wire_labels = list(shared_nets)
cables[cable_name] = build_cable(
cable_name,
wirecount=len(shared_nets),
colors=wire_colors if any(wire_colors) else None,
wirelabels=wire_labels,
category="bundle",
notes=f"Nets: {', '.join(shared_nets)}",
)
# Map pin indices for connection
header_pins = [port_labels.index(net) + 1 for net in shared_nets if net in port_labels]
comp_pins = [
comp.nodes.index(net) + 1 for net in shared_nets if net in comp.nodes
]
cable_wires = list(range(1, len(shared_nets) + 1))
if header_pins and comp_pins and len(header_pins) == len(comp_pins):
connections.append(
build_connection(header_name, header_pins, cable_name, cable_wires, comp_name, comp_pins)
)
return assemble_wireviz_doc(connectors, cables, connections, metadata)
def _find_shared_nets(
subckt: SubcircuitDef,
comp: SpiceComponent,
config: FilterConfig,
netlist: ParsedNetlist,
) -> list[str]:
"""Find nets shared between a subcircuit's ports and a component's nodes.
Returns nets in deterministic order (port order).
"""
port_set = set(subckt.port_names)
comp_nodes = set(comp.nodes)
shared = port_set & comp_nodes
# Filter and sort by port order for determinism
return [
net
for net in subckt.port_names
if net in shared and filter_net(net, config, netlist)
]

View File

View File

@ -0,0 +1,90 @@
"""Optional .asc file parser using spicelib.
LTspice .asc files are complex (coordinates, symbols, attributes) and
spicelib handles them well, but it pulls ~165MB of transitive deps.
This module is only imported when spicelib is available.
Usage:
pip install spice2wireviz[asc]
"""
from __future__ import annotations
from pathlib import Path
from .models import (
ParsedNetlist,
SpiceComponent,
SpicePin,
SubcircuitDef,
SubcircuitInstance,
)
from .netlist import BOUNDARY_PREFIXES, _extract_prefix
def parse_asc(filepath: str | Path) -> ParsedNetlist:
"""Parse an LTspice .asc schematic file via spicelib.
Args:
filepath: Path to .asc file.
Returns:
ParsedNetlist with extracted components and connectivity.
Raises:
ImportError: If spicelib is not installed.
FileNotFoundError: If the .asc file doesn't exist.
"""
try:
from spicelib import AscEditor
except ImportError:
raise ImportError(
"spicelib is required for .asc parsing. "
"Install with: pip install spice2wireviz[asc]"
) from None
path = Path(filepath)
if not path.exists():
raise FileNotFoundError(f"ASC file not found: {path}")
asc = AscEditor(str(path))
subcircuit_defs: dict[str, SubcircuitDef] = {}
instances: list[SubcircuitInstance] = []
top_level_components: list[SpiceComponent] = []
all_nets: set[str] = set()
global_nets: set[str] = set()
for comp in asc.get_components():
ref = comp.get("ref", "")
value = comp.get("value", "")
prefix = _extract_prefix(ref)
if not prefix:
continue
if prefix == "X":
# Subcircuit instance from .asc — limited port info available
inst = SubcircuitInstance(
reference=ref,
subcircuit_name=value,
port_to_net={},
)
instances.append(inst)
elif prefix in BOUNDARY_PREFIXES:
spice_comp = SpiceComponent(
reference=ref,
prefix=prefix,
value=value,
pins=[],
nodes=[],
)
top_level_components.append(spice_comp)
return ParsedNetlist(
subcircuit_defs=subcircuit_defs,
instances=instances,
top_level_components=top_level_components,
all_nets=all_nets,
global_nets=global_nets,
)

View File

@ -0,0 +1,103 @@
"""Pydantic models representing the external interface of SPICE circuits.
These models capture the subset of SPICE netlist data relevant to
wiring documentation: subcircuit port interfaces, connector-like
components (J*, TP*, P*), subcircuit instances (X*), and net connectivity.
"""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field
class PinDirection(str, Enum):
UNKNOWN = "unknown"
INPUT = "input"
OUTPUT = "output"
BIDIRECTIONAL = "bidirectional"
class SpicePin(BaseModel):
"""A single pin on a component or subcircuit port."""
name: str
index: int = Field(ge=1, description="1-based pin index")
direction: PinDirection = PinDirection.UNKNOWN
net_name: str = ""
class SpiceComponent(BaseModel):
"""A component in the netlist (J*, TP*, P*, or other)."""
reference: str = Field(description="Full reference designator, e.g. J1, TP3")
prefix: str = Field(description="Component prefix, e.g. J, TP, P")
value: str = ""
pins: list[SpicePin] = Field(default_factory=list)
nodes: list[str] = Field(default_factory=list, description="Net names connected to each pin")
attributes: dict[str, str] = Field(default_factory=dict)
subcircuit_scope: str = Field(
default="", description="Name of .subckt this component is inside, empty for top-level"
)
class SubcircuitDef(BaseModel):
"""A .subckt definition with its port interface and internal boundary components."""
name: str
port_names: list[str] = Field(description="Ordered port names from .subckt line")
boundary_components: list[SpiceComponent] = Field(
default_factory=list,
description="J*/TP*/P* components defined inside this subcircuit",
)
parameters: dict[str, str] = Field(default_factory=dict)
class SubcircuitInstance(BaseModel):
"""An X* instantiation of a subcircuit."""
reference: str = Field(description="Instance reference, e.g. X1")
subcircuit_name: str
port_to_net: dict[str, str] = Field(
description="Mapping of subcircuit port name -> net name at instantiation site"
)
attributes: dict[str, str] = Field(default_factory=dict)
class ParsedNetlist(BaseModel):
"""Complete parsed representation of a SPICE netlist's external interface."""
subcircuit_defs: dict[str, SubcircuitDef] = Field(
default_factory=dict, description="Subcircuit name -> definition"
)
instances: list[SubcircuitInstance] = Field(
default_factory=list, description="All X* instances at top level"
)
top_level_components: list[SpiceComponent] = Field(
default_factory=list, description="J*/TP*/P* components at top level"
)
all_nets: set[str] = Field(default_factory=set, description="All net names encountered")
global_nets: set[str] = Field(
default_factory=set, description="Nets declared .global or with $G_ prefix"
)
model_config = {"arbitrary_types_allowed": True}
def get_subcircuit(self, name: str) -> SubcircuitDef | None:
return self.subcircuit_defs.get(name)
def list_subcircuit_names(self) -> list[str]:
return sorted(self.subcircuit_defs.keys())
def get_components_by_prefix(self, *prefixes: str) -> list[SpiceComponent]:
prefixes_upper = {p.upper() for p in prefixes}
return [c for c in self.top_level_components if c.prefix.upper() in prefixes_upper]
def is_power_net(self, net: str) -> bool:
upper = net.upper()
return upper in {"VCC", "VDD", "V+", "V-", "VEE", "VSS", "AVCC", "AVDD", "DVCC", "DVDD"}
def is_ground_net(self, net: str) -> bool:
upper = net.upper()
return upper in {"GND", "AGND", "DGND", "GND!", "EARTH", "VSS"} or net == "0"

View File

@ -0,0 +1,356 @@
"""Custom lightweight SPICE netlist parser.
Extracts subcircuit definitions, instances, and connector-like components
from .net/.cir/.sp files. Only parses the structural elements needed for
wiring documentation ignores simulation directives, models, etc.
SPICE netlist format reference:
- Lines starting with * are comments
- ; starts an inline comment
- + at line start is a continuation of the previous line
- .subckt Name port1 port2 ... portN [params]
- .ends [Name]
- X<ref> node1 node2 ... nodeN SubcircuitName [params]
- Component lines: <prefix><ref> node1 node2 ... [value] [params]
- .global net1 net2 ...
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from .models import (
ParsedNetlist,
PinDirection,
SpiceComponent,
SpicePin,
SubcircuitDef,
SubcircuitInstance,
)
# Prefixes that represent physical connectors / test points
BOUNDARY_PREFIXES = {"J", "TP", "P"}
# Prefixes we recognize as external-facing components
RECOGNIZED_PREFIXES = {"J", "TP", "P", "X"}
# Known power net patterns
_POWER_PATTERN = re.compile(
r"^(V(CC|DD|EE|SS|[+-])|A?V(CC|DD)|D?V(CC|DD))$", re.IGNORECASE
)
# Known ground net patterns
_GROUND_PATTERN = re.compile(r"^(GND!?|AGND|DGND|EARTH|VSS|0)$", re.IGNORECASE)
def _strip_comment(line: str) -> str:
"""Remove inline ; comments, respecting that ; inside quotes is literal."""
# Simple approach: split on first ; not inside quotes
in_quote = False
for i, ch in enumerate(line):
if ch == '"':
in_quote = not in_quote
elif ch == ";" and not in_quote:
return line[:i].rstrip()
return line.rstrip()
def _join_continuation_lines(lines: list[str]) -> list[str]:
"""Join + continuation lines with their predecessor."""
result: list[str] = []
for line in lines:
stripped = line.lstrip()
if stripped.startswith("+") and result:
# Continuation: append to previous line
result[-1] = result[-1] + " " + stripped[1:].lstrip()
else:
result.append(line)
return result
def _extract_prefix(reference: str) -> str:
"""Extract the alphabetic prefix from a reference designator.
Examples: J1 -> J, TP3 -> TP, X_amp -> X, R12 -> R
"""
match = re.match(r"^([A-Za-z]+)", reference)
return match.group(1).upper() if match else ""
def _parse_params(tokens: list[str]) -> dict[str, str]:
"""Extract key=value parameters from token list."""
params: dict[str, str] = {}
for token in tokens:
if "=" in token:
key, _, val = token.partition("=")
params[key.strip()] = val.strip()
return params
def _detect_pin_direction(name: str) -> PinDirection:
"""Heuristic pin direction from name."""
upper = name.upper()
if any(tag in upper for tag in ("_IN", "INPUT", "RXD", "MISO", "SDI")):
return PinDirection.INPUT
if any(tag in upper for tag in ("_OUT", "OUTPUT", "TXD", "MOSI", "SDO")):
return PinDirection.OUTPUT
if any(tag in upper for tag in ("SDA", "SCL", "IO", "BIDIR")):
return PinDirection.BIDIRECTIONAL
return PinDirection.UNKNOWN
def parse_netlist(source: str | Path) -> ParsedNetlist:
"""Parse a SPICE netlist file or string into a ParsedNetlist.
Args:
source: File path (.net, .cir, .sp) or raw SPICE text.
Returns:
ParsedNetlist with subcircuit defs, instances, and components.
"""
if isinstance(source, Path) or (
isinstance(source, str) and not "\n" in source and Path(source).suffix in {".net", ".cir", ".sp"}
):
path = Path(source)
if path.exists():
text = path.read_text(encoding="utf-8", errors="replace")
else:
raise FileNotFoundError(f"Netlist file not found: {path}")
else:
text = source
return _parse_text(text)
def _parse_text(text: str) -> ParsedNetlist:
"""Parse raw SPICE netlist text."""
# Normalize line endings and strip comments
raw_lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
# Strip full-line comments (lines starting with *)
cleaned: list[str] = []
for line in raw_lines:
stripped = line.strip()
if not stripped or stripped.startswith("*"):
continue
cleaned.append(_strip_comment(line))
# Join continuation lines
lines = _join_continuation_lines(cleaned)
subcircuit_defs: dict[str, SubcircuitDef] = {}
instances: list[SubcircuitInstance] = []
top_level_components: list[SpiceComponent] = []
all_nets: set[str] = set()
global_nets: set[str] = set()
# Track scope: None = top-level, str = inside .subckt <name>
current_subckt: SubcircuitDef | None = None
for line in lines:
stripped = line.strip()
if not stripped:
continue
tokens = stripped.split()
directive = tokens[0].lower()
# --- Directives ---
if directive == ".subckt":
name, ports, params = _parse_subckt_line(tokens)
current_subckt = SubcircuitDef(
name=name, port_names=ports, parameters=params
)
all_nets.update(ports)
continue
if directive in (".ends", ".end"):
if current_subckt:
subcircuit_defs[current_subckt.name] = current_subckt
current_subckt = None
continue
if directive == ".global":
for net in tokens[1:]:
global_nets.add(net)
all_nets.add(net)
continue
# Skip other directives (.model, .param, .lib, .include, .tran, etc.)
if directive.startswith("."):
continue
# --- Component / Instance lines ---
ref = tokens[0]
prefix = _extract_prefix(ref)
if not prefix:
continue
if prefix == "X":
# Subcircuit instance: X<ref> node1 node2 ... SubcircuitName [params]
instance = _parse_x_instance(tokens, subcircuit_defs)
if instance:
if current_subckt is None:
instances.append(instance)
# Track nets
for net in instance.port_to_net.values():
all_nets.add(net)
if net.startswith("$G_"):
global_nets.add(net)
elif prefix in BOUNDARY_PREFIXES:
# Connector/test point/plug component
comp = _parse_boundary_component(tokens, prefix)
if comp:
comp.subcircuit_scope = current_subckt.name if current_subckt else ""
if current_subckt:
current_subckt.boundary_components.append(comp)
else:
top_level_components.append(comp)
# Track nets
for node in comp.nodes:
all_nets.add(node)
if node.startswith("$G_"):
global_nets.add(node)
# Post-processing: warn about undefined subcircuits
known_subckt_names = set(subcircuit_defs.keys())
for inst in instances:
if inst.subcircuit_name not in known_subckt_names:
print(
f"Warning: subcircuit '{inst.subcircuit_name}' referenced by "
f"{inst.reference} is not defined in this netlist",
file=sys.stderr,
)
return ParsedNetlist(
subcircuit_defs=subcircuit_defs,
instances=instances,
top_level_components=top_level_components,
all_nets=all_nets,
global_nets=global_nets,
)
def _parse_subckt_line(tokens: list[str]) -> tuple[str, list[str], dict[str, str]]:
"""Parse a .subckt line into (name, port_names, parameters).
Format: .subckt Name port1 port2 ... [param1=val1 ...]
"""
name = tokens[1]
ports: list[str] = []
param_tokens: list[str] = []
for token in tokens[2:]:
if "=" in token:
param_tokens.append(token)
else:
ports.append(token)
return name, ports, _parse_params(param_tokens)
def _parse_x_instance(
tokens: list[str], known_subcircuits: dict[str, SubcircuitDef]
) -> SubcircuitInstance | None:
"""Parse an X instance line.
Format: X<ref> node1 node2 ... SubcircuitName [param1=val1 ...]
The subcircuit name is the last non-parameter token. Nodes are everything
between the reference and the subcircuit name.
"""
ref = tokens[0]
# Separate parameter tokens (key=value) from positional tokens
positional: list[str] = []
params: list[str] = []
for token in tokens[1:]:
if "=" in token:
params.append(token)
else:
positional.append(token)
if len(positional) < 1:
print(f"Warning: malformed X instance line: {' '.join(tokens)}", file=sys.stderr)
return None
# Last positional token is the subcircuit name
subckt_name = positional[-1]
nodes = positional[:-1]
# Build port-to-net mapping using subcircuit definition if available
port_to_net: dict[str, str] = {}
subckt_def = known_subcircuits.get(subckt_name)
if subckt_def:
for i, port_name in enumerate(subckt_def.port_names):
if i < len(nodes):
port_to_net[port_name] = nodes[i]
else:
# No definition available; use positional indices as port names
for i, node in enumerate(nodes):
port_to_net[f"port{i + 1}"] = node
return SubcircuitInstance(
reference=ref,
subcircuit_name=subckt_name,
port_to_net=port_to_net,
attributes=_parse_params(params),
)
def _parse_boundary_component(tokens: list[str], prefix: str) -> SpiceComponent | None:
"""Parse a J*/TP*/P* component line.
Generic SPICE component format:
<ref> node1 [node2 ...] [value] [params]
For connectors, we treat all non-parameter tokens after the ref as nodes,
except the last one which may be a model/value name (if it doesn't look
like a net name).
"""
ref = tokens[0]
positional: list[str] = []
param_tokens: list[str] = []
for token in tokens[1:]:
if "=" in token:
param_tokens.append(token)
else:
positional.append(token)
# Heuristic: if last positional looks like a model name (contains only
# alphanumeric/underscore and doesn't match net patterns), treat it as value
value = ""
nodes = positional
if len(positional) >= 2:
last = positional[-1]
# If it's not purely numeric and not a known net pattern, it's likely a value/model
if re.match(r"^[A-Za-z_]\w*$", last) and not _POWER_PATTERN.match(
last
) and not _GROUND_PATTERN.match(last):
value = last
nodes = positional[:-1]
pins = [
SpicePin(
name=node,
index=i + 1,
direction=_detect_pin_direction(node),
net_name=node,
)
for i, node in enumerate(nodes)
]
return SpiceComponent(
reference=ref,
prefix=prefix,
value=value,
pins=pins,
nodes=nodes,
attributes=_parse_params(param_tokens),
)

0
tests/__init__.py Normal file
View File

40
tests/fixtures/hierarchical.net vendored Normal file
View File

@ -0,0 +1,40 @@
* Hierarchical netlist with nested subcircuits and global nets
* Tests continuation lines, parameters, .global directive
.global VCC GND
.subckt regulator VIN VOUT GND
+ ENABLE=1
R1 VIN N001 100
C1 N001 GND 10u
U1 N001 VOUT GND VCC LDO EN=ENABLE
J1 VIN GND INPUT_CONN
TP1 VOUT
.ends regulator
.subckt sensor_module SDA SCL VCC GND ALERT
* I2C sensor with pullups and test points
R_SDA SDA VCC 4.7k
R_SCL SCL VCC 4.7k
U1 SDA SCL VCC GND ALERT BME280
J1 SDA SCL VCC GND ALERT I2C_HDR
TP1 ALERT
.ends sensor_module
.subckt main_board VCC GND SDA SCL ALERT USB_D+ USB_D-
; Main MCU board with USB and I2C
U1 VCC GND SDA SCL USB_D+ USB_D- MCU
J1 USB_D+ USB_D- GND USB_CONN
J2 SDA SCL VCC GND I2C_CONN
P1 VCC GND POWER_PLUG
.ends main_board
* Top-level system wiring
X_REG VIN_RAW V3V3 GND regulator ENABLE=1
X_SENSOR I2C_SDA I2C_SCL V3V3 GND ALERT_SIG sensor_module
X_MAIN V3V3 GND I2C_SDA I2C_SCL ALERT_SIG USB_DP USB_DM main_board
* External connectors
J_PWR VIN_RAW GND BARREL_JACK
J_USB USB_DP USB_DM GND USB_B_CONN
TP_3V3 V3V3

27
tests/fixtures/multi_board.net vendored Normal file
View File

@ -0,0 +1,27 @@
* Multi-board system: power supply, amplifier, and I/O board
* Demonstrates inter-module wiring
.subckt power_supply VCC GND
J1 AC_IN AC_GND AC_INLET
.ends power_supply
.subckt amplifier VIN GND VOUT
R1 VIN N001 10k
C1 N001 GND 100n
J1 VIN GND INPUT_CONN
TP1 VOUT
.ends amplifier
.subckt io_board SIG_IN SIG_OUT GND CTRL
J1 SIG_IN SIG_OUT DB9_CONN
J2 CTRL GND CTRL_CONN
.ends io_board
* Top-level instantiation
X1 VCC GND power_supply
X2 VCC GND AUDIO_OUT amplifier
X3 AUDIO_OUT CTRL_SIG GND ENABLE io_board
* Top-level connectors
J_CHASSIS GND EARTH chassis_gnd
TP_VCC VCC

14
tests/fixtures/simple_board.net vendored Normal file
View File

@ -0,0 +1,14 @@
* Simple board netlist with one subcircuit
* Has connectors J1 (power), J2 (signal I/O), and test point TP1
.subckt amplifier_board VIN GND VOUT SIGNAL_IN
* Internal components (passives, ICs — not relevant to wiring)
R1 VIN N001 10k
R2 N001 GND 10k
U1 N001 VOUT GND VCC opamp
* Boundary components (connectors/test points visible at board edge)
J1 VIN GND PWR_CONN
J2 SIGNAL_IN VOUT SIG_CONN
TP1 N001
.ends amplifier_board

130
tests/test_cli.py Normal file
View File

@ -0,0 +1,130 @@
"""Tests for the Click CLI."""
from pathlib import Path
from click.testing import CliRunner
from spice2wireviz.cli import main
FIXTURES = Path(__file__).parent / "fixtures"
class TestCLIBasic:
def test_version(self):
runner = CliRunner()
result = runner.invoke(main, ["--version"])
assert result.exit_code == 0
assert "2026-02-13" in result.output
def test_list_subcircuits(self):
runner = CliRunner()
result = runner.invoke(main, [str(FIXTURES / "multi_board.net"), "--list-subcircuits"])
assert result.exit_code == 0
assert "power_supply" in result.output
assert "amplifier" in result.output
assert "io_board" in result.output
def test_list_components(self):
runner = CliRunner()
result = runner.invoke(main, [str(FIXTURES / "multi_board.net"), "--list-components"])
assert result.exit_code == 0
assert "J_CHASSIS" in result.output
assert "TP_VCC" in result.output
def test_nonexistent_file(self):
runner = CliRunner()
result = runner.invoke(main, ["/nonexistent/file.net"])
assert result.exit_code != 0
class TestCLISingleModule:
def test_single_mode_explicit(self):
runner = CliRunner()
result = runner.invoke(
main,
[str(FIXTURES / "simple_board.net"), "-m", "single", "-s", "amplifier_board"],
)
assert result.exit_code == 0
assert "connectors:" in result.output
assert "amplifier_board" in result.output
def test_single_mode_auto_detect(self):
runner = CliRunner()
result = runner.invoke(
main, [str(FIXTURES / "simple_board.net"), "-s", "amplifier_board"]
)
assert result.exit_code == 0
assert "connectors:" in result.output
def test_single_mode_only_subcircuit(self):
"""When netlist has one subcircuit and no instances, auto-detect single mode."""
runner = CliRunner()
result = runner.invoke(main, [str(FIXTURES / "simple_board.net")])
assert result.exit_code == 0
assert "connectors:" in result.output
def test_dry_run(self):
runner = CliRunner()
result = runner.invoke(
main,
[str(FIXTURES / "simple_board.net"), "-s", "amplifier_board", "--dry-run"],
)
assert result.exit_code == 0
assert "Mode: single" in result.output
assert "Connectors:" in result.output
class TestCLIInterModule:
def test_inter_mode_explicit(self):
runner = CliRunner()
result = runner.invoke(
main, [str(FIXTURES / "multi_board.net"), "-m", "inter"]
)
assert result.exit_code == 0
assert "connectors:" in result.output
assert "cables:" in result.output
def test_inter_mode_auto_detect(self):
runner = CliRunner()
result = runner.invoke(main, [str(FIXTURES / "multi_board.net")])
assert result.exit_code == 0
assert "connectors:" in result.output
def test_output_to_file(self, tmp_path):
out = tmp_path / "output.yml"
runner = CliRunner()
result = runner.invoke(
main, [str(FIXTURES / "multi_board.net"), "-o", str(out)]
)
assert result.exit_code == 0
assert out.exists()
content = out.read_text()
assert "connectors:" in content
class TestCLIFiltering:
def test_no_ground(self):
runner = CliRunner()
result = runner.invoke(
main, [str(FIXTURES / "multi_board.net"), "--no-ground"]
)
assert result.exit_code == 0
# GND should not appear in pinlabels
assert "- GND" not in result.output
def test_include_prefixes(self):
runner = CliRunner()
result = runner.invoke(
main,
[str(FIXTURES / "multi_board.net"), "--include-prefixes", "J"],
)
assert result.exit_code == 0
def test_exclude_refs(self):
runner = CliRunner()
result = runner.invoke(
main,
[str(FIXTURES / "multi_board.net"), "--exclude-refs", "X1,TP_VCC"],
)
assert result.exit_code == 0
assert "X1:" not in result.output

167
tests/test_filter.py Normal file
View File

@ -0,0 +1,167 @@
"""Tests for the filter engine."""
from spice2wireviz.filter import FilterConfig, apply_filters, filter_component, filter_instance, filter_net
from spice2wireviz.parser.models import (
ParsedNetlist,
SpiceComponent,
SpicePin,
SubcircuitInstance,
)
def _make_netlist(**kwargs) -> ParsedNetlist:
defaults = {
"subcircuit_defs": {},
"instances": [],
"top_level_components": [],
"all_nets": set(),
"global_nets": set(),
}
defaults.update(kwargs)
return ParsedNetlist(**defaults)
def _make_component(ref: str, prefix: str, nodes: list[str] | None = None) -> SpiceComponent:
nodes = nodes or []
return SpiceComponent(
reference=ref,
prefix=prefix,
nodes=nodes,
pins=[SpicePin(name=n, index=i + 1, net_name=n) for i, n in enumerate(nodes)],
)
def _make_instance(ref: str, subckt: str, port_to_net: dict[str, str]) -> SubcircuitInstance:
return SubcircuitInstance(reference=ref, subcircuit_name=subckt, port_to_net=port_to_net)
class TestComponentFilter:
def test_default_includes_boundary(self):
config = FilterConfig()
netlist = _make_netlist()
assert filter_component(_make_component("J1", "J"), config, netlist)
assert filter_component(_make_component("TP1", "TP"), config, netlist)
assert filter_component(_make_component("P1", "P"), config, netlist)
def test_exclude_prefix(self):
config = FilterConfig(exclude_prefixes=["TP"])
netlist = _make_netlist()
assert filter_component(_make_component("J1", "J"), config, netlist)
assert not filter_component(_make_component("TP1", "TP"), config, netlist)
def test_include_prefix_restricts(self):
config = FilterConfig(include_prefixes=["J"])
netlist = _make_netlist()
assert filter_component(_make_component("J1", "J"), config, netlist)
assert not filter_component(_make_component("TP1", "TP"), config, netlist)
def test_exclude_ref(self):
config = FilterConfig(exclude_refs=["J2"])
netlist = _make_netlist()
assert filter_component(_make_component("J1", "J"), config, netlist)
assert not filter_component(_make_component("J2", "J"), config, netlist)
def test_include_ref(self):
config = FilterConfig(include_refs=["J1"])
netlist = _make_netlist()
assert filter_component(_make_component("J1", "J"), config, netlist)
assert not filter_component(_make_component("J2", "J"), config, netlist)
class TestInstanceFilter:
def test_default_includes_x(self):
config = FilterConfig()
netlist = _make_netlist()
inst = _make_instance("X1", "amp", {"VIN": "NET1"})
assert filter_instance(inst, config, netlist)
def test_exclude_subcircuit(self):
config = FilterConfig(exclude_subcircuits=["power_supply"])
netlist = _make_netlist()
assert not filter_instance(
_make_instance("X1", "power_supply", {}), config, netlist
)
assert filter_instance(
_make_instance("X2", "amplifier", {}), config, netlist
)
def test_include_subcircuit(self):
config = FilterConfig(include_subcircuits=["amplifier"])
netlist = _make_netlist()
assert filter_instance(
_make_instance("X2", "amplifier", {}), config, netlist
)
assert not filter_instance(
_make_instance("X1", "power_supply", {}), config, netlist
)
def test_exclude_ref(self):
config = FilterConfig(exclude_refs=["X3"])
netlist = _make_netlist()
assert not filter_instance(
_make_instance("X3", "io_board", {}), config, netlist
)
class TestNetFilter:
def test_default_shows_all(self):
config = FilterConfig()
netlist = _make_netlist()
assert filter_net("VCC", config, netlist)
assert filter_net("GND", config, netlist)
assert filter_net("SIGNAL", config, netlist)
def test_hide_ground(self):
config = FilterConfig(show_ground=False)
netlist = _make_netlist()
assert not filter_net("GND", config, netlist)
assert not filter_net("AGND", config, netlist)
assert filter_net("VCC", config, netlist)
def test_hide_power(self):
config = FilterConfig(show_power=False)
netlist = _make_netlist()
assert not filter_net("VCC", config, netlist)
assert not filter_net("VDD", config, netlist)
assert filter_net("GND", config, netlist)
def test_include_nets_glob(self):
config = FilterConfig(include_nets=["SIG_*"])
netlist = _make_netlist()
assert filter_net("SIG_IN", config, netlist)
assert filter_net("SIG_OUT", config, netlist)
assert not filter_net("VCC", config, netlist)
def test_exclude_nets_glob(self):
config = FilterConfig(exclude_nets=["N0*"])
netlist = _make_netlist()
assert not filter_net("N001", config, netlist)
assert filter_net("SIGNAL", config, netlist)
class TestApplyFilters:
def test_filters_components(self):
comps = [
_make_component("J1", "J", ["NET1"]),
_make_component("TP1", "TP", ["NET2"]),
_make_component("R1", "R", ["NET3"]),
]
netlist = _make_netlist(top_level_components=comps)
config = FilterConfig() # default: J, TP, P, X
filtered = apply_filters(netlist, config)
refs = [c.reference for c in filtered.top_level_components]
assert "J1" in refs
assert "TP1" in refs
assert "R1" not in refs # R not in default include
def test_filters_instances(self):
insts = [
_make_instance("X1", "power", {"VCC": "NET1"}),
_make_instance("X2", "amp", {"VIN": "NET2"}),
]
netlist = _make_netlist(instances=insts)
config = FilterConfig(exclude_refs=["X1"])
filtered = apply_filters(netlist, config)
refs = [i.reference for i in filtered.instances]
assert "X1" not in refs
assert "X2" in refs

145
tests/test_inter_module.py Normal file
View File

@ -0,0 +1,145 @@
"""Tests for the inter-module mapper."""
from pathlib import Path
from spice2wireviz.filter import FilterConfig
from spice2wireviz.mapper.inter_module import map_inter_module
from spice2wireviz.parser.netlist import parse_netlist
FIXTURES = Path(__file__).parent / "fixtures"
class TestInterModuleMapping:
def test_multi_board_connectors(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
connectors = result["connectors"]
# Should have connectors for each X instance and top-level components
assert "X1" in connectors
assert "X2" in connectors
assert "X3" in connectors
assert "J_CHASSIS" in connectors
assert "TP_VCC" in connectors
def test_instance_connector_type(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
x1 = result["connectors"]["X1"]
assert x1["type"] == "power_supply"
def test_instance_pinlabels(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
x2 = result["connectors"]["X2"]
assert "VIN" in x2["pinlabels"]
assert "GND" in x2["pinlabels"]
assert "VOUT" in x2["pinlabels"]
def test_cables_for_shared_nets(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
# Should have cables connecting modules via shared nets
cables = result["cables"]
assert len(cables) >= 1
def test_connections_exist(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
connections = result["connections"]
assert len(connections) >= 1
def test_traceability_notes(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
x1 = result["connectors"]["X1"]
assert "SPICE instance" in x1["notes"]
def test_top_level_test_point_style(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
tp = result["connectors"]["TP_VCC"]
assert tp.get("style") == "simple"
class TestInterModuleFiltering:
def test_exclude_instance(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
config = FilterConfig(exclude_refs=["X1"])
result = map_inter_module(netlist, config)
assert "X1" not in result["connectors"]
assert "X2" in result["connectors"]
def test_exclude_subcircuit(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
config = FilterConfig(exclude_subcircuits=["power_supply"])
result = map_inter_module(netlist, config)
assert "X1" not in result["connectors"]
def test_no_ground_filter(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
config = FilterConfig(show_ground=False)
result = map_inter_module(netlist, config)
# GND should not appear in any connector's pinlabels
for name, conn in result["connectors"].items():
if "pinlabels" in conn:
assert "GND" not in conn["pinlabels"], f"GND found in {name}"
def test_no_power_filter(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
config = FilterConfig(show_power=False)
result = map_inter_module(netlist, config)
for name, conn in result["connectors"].items():
if "pinlabels" in conn:
assert "VCC" not in conn["pinlabels"], f"VCC found in {name}"
class TestHierarchicalInterModule:
def test_hierarchical_instances(self):
netlist = parse_netlist(FIXTURES / "hierarchical.net")
result = map_inter_module(netlist)
connectors = result["connectors"]
assert "X_REG" in connectors
assert "X_SENSOR" in connectors
assert "X_MAIN" in connectors
def test_external_connectors(self):
netlist = parse_netlist(FIXTURES / "hierarchical.net")
result = map_inter_module(netlist)
connectors = result["connectors"]
assert "J_PWR" in connectors
assert "J_USB" in connectors
assert "TP_3V3" in connectors
def test_grouped_parallel_wires(self):
"""Parallel wires between same modules should be grouped."""
netlist = parse_netlist(FIXTURES / "hierarchical.net")
config = FilterConfig(group_parallel_wires=True)
result = map_inter_module(netlist, config)
# With grouping, fewer cables than total net connections
cables = result["cables"]
connections = result["connections"]
assert len(cables) == len(connections)
def test_ungrouped_wires(self):
"""Without grouping, each wire gets its own cable."""
netlist = parse_netlist(FIXTURES / "hierarchical.net")
config = FilterConfig(group_parallel_wires=False)
result = map_inter_module(netlist, config)
# Each cable should have wirecount=1
for cable in result["cables"].values():
assert cable.get("wirecount", 1) == 1 or len(cable.get("colors", [])) == 1

View File

@ -0,0 +1,171 @@
"""Tests for the SPICE netlist parser."""
from pathlib import Path
import pytest
from spice2wireviz.parser.netlist import parse_netlist
FIXTURES = Path(__file__).parent / "fixtures"
class TestBasicParsing:
def test_parse_simple_board(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
assert "amplifier_board" in netlist.subcircuit_defs
subckt = netlist.subcircuit_defs["amplifier_board"]
assert subckt.port_names == ["VIN", "GND", "VOUT", "SIGNAL_IN"]
def test_parse_boundary_components(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
subckt = netlist.subcircuit_defs["amplifier_board"]
refs = [c.reference for c in subckt.boundary_components]
assert "J1" in refs
assert "J2" in refs
assert "TP1" in refs
assert len(subckt.boundary_components) == 3
def test_boundary_component_nodes(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
subckt = netlist.subcircuit_defs["amplifier_board"]
j1 = next(c for c in subckt.boundary_components if c.reference == "J1")
assert j1.nodes == ["VIN", "GND"]
assert j1.value == "PWR_CONN"
def test_test_point_single_node(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
subckt = netlist.subcircuit_defs["amplifier_board"]
tp1 = next(c for c in subckt.boundary_components if c.reference == "TP1")
assert tp1.nodes == ["N001"]
assert tp1.prefix == "TP"
class TestMultiBoardParsing:
def test_parse_multi_board(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
assert len(netlist.subcircuit_defs) == 3
assert "power_supply" in netlist.subcircuit_defs
assert "amplifier" in netlist.subcircuit_defs
assert "io_board" in netlist.subcircuit_defs
def test_instances(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
refs = [inst.reference for inst in netlist.instances]
assert "X1" in refs
assert "X2" in refs
assert "X3" in refs
def test_instance_port_mapping(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
x2 = next(i for i in netlist.instances if i.reference == "X2")
assert x2.subcircuit_name == "amplifier"
assert x2.port_to_net == {"VIN": "VCC", "GND": "GND", "VOUT": "AUDIO_OUT"}
def test_top_level_components(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
refs = [c.reference for c in netlist.top_level_components]
assert "J_CHASSIS" in refs
assert "TP_VCC" in refs
def test_top_level_connector_nodes(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
j_chassis = next(c for c in netlist.top_level_components if c.reference == "J_CHASSIS")
assert "GND" in j_chassis.nodes
assert "EARTH" in j_chassis.nodes
class TestHierarchicalParsing:
def test_global_nets(self):
netlist = parse_netlist(FIXTURES / "hierarchical.net")
assert "VCC" in netlist.global_nets
assert "GND" in netlist.global_nets
def test_continuation_lines(self):
netlist = parse_netlist(FIXTURES / "hierarchical.net")
reg = netlist.subcircuit_defs["regulator"]
# The continuation line should have been joined
assert "ENABLE" in reg.parameters or len(reg.port_names) >= 3
def test_inline_comments(self):
"""Inline ; comments should be stripped."""
netlist = parse_netlist(FIXTURES / "hierarchical.net")
assert "main_board" in netlist.subcircuit_defs
def test_all_subcircuits_found(self):
netlist = parse_netlist(FIXTURES / "hierarchical.net")
assert len(netlist.subcircuit_defs) == 3
assert "regulator" in netlist.subcircuit_defs
assert "sensor_module" in netlist.subcircuit_defs
assert "main_board" in netlist.subcircuit_defs
class TestStringParsing:
def test_parse_from_string(self):
text = """\
.subckt simple A B
J1 A B CONN
.ends simple
"""
netlist = parse_netlist(text)
assert "simple" in netlist.subcircuit_defs
subckt = netlist.subcircuit_defs["simple"]
assert subckt.port_names == ["A", "B"]
assert len(subckt.boundary_components) == 1
def test_empty_netlist(self):
netlist = parse_netlist("* Just a comment\n")
assert len(netlist.subcircuit_defs) == 0
assert len(netlist.instances) == 0
def test_continuation_line_joining(self):
text = """\
.subckt wide A B C
+ D E F
J1 A B CONN
.ends wide
"""
netlist = parse_netlist(text)
subckt = netlist.subcircuit_defs["wide"]
assert subckt.port_names == ["A", "B", "C", "D", "E", "F"]
class TestNetClassification:
def test_ground_nets(self):
text = ".subckt test GND AGND DGND VCC\n.ends test\n"
netlist = parse_netlist(text)
assert netlist.is_ground_net("GND")
assert netlist.is_ground_net("AGND")
assert netlist.is_ground_net("0")
assert not netlist.is_ground_net("VCC")
def test_power_nets(self):
text = ".subckt test VCC VDD GND\n.ends test\n"
netlist = parse_netlist(text)
assert netlist.is_power_net("VCC")
assert netlist.is_power_net("VDD")
assert not netlist.is_power_net("GND")
assert not netlist.is_power_net("SIGNAL")
class TestEdgeCases:
def test_nonexistent_file(self):
with pytest.raises(FileNotFoundError):
parse_netlist(Path("/nonexistent/file.net"))
def test_missing_subcircuit_warning(self, capsys):
text = """\
X1 A B C undefined_subckt
"""
netlist = parse_netlist(text)
assert len(netlist.instances) == 1
captured = capsys.readouterr()
assert "undefined_subckt" in captured.err
def test_x_instance_without_subckt_def(self):
text = "X1 NET1 NET2 NET3 mystery_chip\n"
netlist = parse_netlist(text)
inst = netlist.instances[0]
assert inst.subcircuit_name == "mystery_chip"
# Without definition, uses positional port names
assert "port1" in inst.port_to_net
assert inst.port_to_net["port1"] == "NET1"

145
tests/test_roundtrip.py Normal file
View File

@ -0,0 +1,145 @@
"""Roundtrip tests: parse SPICE -> emit YAML -> validate with WireViz.
These tests verify the generated YAML is structurally valid by feeding
it to wireviz.wireviz.parse() and checking it doesn't raise exceptions.
"""
from pathlib import Path
import pytest
import yaml
from spice2wireviz.emitter.yaml_emitter import emit_yaml
from spice2wireviz.filter import FilterConfig
from spice2wireviz.mapper.inter_module import map_inter_module
from spice2wireviz.mapper.single_module import map_single_module
from spice2wireviz.parser.netlist import parse_netlist
FIXTURES = Path(__file__).parent / "fixtures"
def _has_wireviz() -> bool:
try:
from wireviz.wireviz import parse # noqa: F401
return True
except ImportError:
return False
class TestYamlValidity:
"""Verify generated YAML is valid YAML and has required WireViz keys."""
def test_single_module_yaml_structure(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
yaml_str = emit_yaml(result)
parsed = yaml.safe_load(yaml_str)
assert "connectors" in parsed
assert "cables" in parsed
assert "connections" in parsed
assert isinstance(parsed["connectors"], dict)
assert isinstance(parsed["cables"], dict)
assert isinstance(parsed["connections"], list)
def test_inter_module_yaml_structure(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
yaml_str = emit_yaml(result)
parsed = yaml.safe_load(yaml_str)
assert "connectors" in parsed
assert "cables" in parsed
assert "connections" in parsed
def test_hierarchical_yaml_structure(self):
netlist = parse_netlist(FIXTURES / "hierarchical.net")
result = map_inter_module(netlist)
yaml_str = emit_yaml(result)
parsed = yaml.safe_load(yaml_str)
assert "connectors" in parsed
assert len(parsed["connectors"]) >= 6 # 3 instances + 3 top-level
def test_connector_has_pin_spec(self):
"""Every connector must have at least one of pincount/pins/pinlabels."""
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
yaml_str = emit_yaml(result)
parsed = yaml.safe_load(yaml_str)
for name, conn in parsed["connectors"].items():
has_pins = any(k in conn for k in ("pincount", "pins", "pinlabels"))
assert has_pins, f"Connector '{name}' missing pin specification"
def test_cable_has_wire_spec(self):
"""Every cable must have wirecount or colors."""
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
yaml_str = emit_yaml(result)
parsed = yaml.safe_load(yaml_str)
for name, cable in parsed["cables"].items():
has_wires = "wirecount" in cable or "colors" in cable
assert has_wires, f"Cable '{name}' missing wire specification"
def test_connection_set_structure(self):
"""Each connection set should be a list of dicts."""
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
yaml_str = emit_yaml(result)
parsed = yaml.safe_load(yaml_str)
for i, conn_set in enumerate(parsed["connections"]):
assert isinstance(conn_set, list), f"Connection set {i} is not a list"
assert len(conn_set) == 3, f"Connection set {i} doesn't have 3 elements"
for entry in conn_set:
assert isinstance(entry, dict), f"Connection set {i} entry is not a dict"
@pytest.mark.skipif(not _has_wireviz(), reason="WireViz not installed")
class TestWireVizRoundtrip:
"""Feed generated YAML to WireViz's parse() to verify full compatibility."""
def test_single_module_roundtrip(self):
from wireviz.wireviz import parse as wv_parse
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
# WireViz parse() accepts dicts directly
harness = wv_parse(result, return_types="harness")
assert harness is not None
def test_inter_module_roundtrip(self):
from wireviz.wireviz import parse as wv_parse
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
harness = wv_parse(result, return_types="harness")
assert harness is not None
def test_hierarchical_roundtrip(self):
from wireviz.wireviz import parse as wv_parse
netlist = parse_netlist(FIXTURES / "hierarchical.net")
result = map_inter_module(netlist)
harness = wv_parse(result, return_types="harness")
assert harness is not None
def test_filtered_roundtrip(self):
from wireviz.wireviz import parse as wv_parse
netlist = parse_netlist(FIXTURES / "hierarchical.net")
config = FilterConfig(show_ground=False, show_power=False)
result = map_inter_module(netlist, config)
harness = wv_parse(result, return_types="harness")
assert harness is not None
def test_render_svg(self, tmp_path):
from wireviz.wireviz import parse as wv_parse
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
svg = wv_parse(result, return_types="svg")
assert svg is not None
assert len(svg) > 100 # Should be a real SVG

104
tests/test_single_module.py Normal file
View File

@ -0,0 +1,104 @@
"""Tests for the single-module mapper."""
from pathlib import Path
import pytest
from spice2wireviz.filter import FilterConfig
from spice2wireviz.mapper.single_module import map_single_module
from spice2wireviz.parser.netlist import parse_netlist
FIXTURES = Path(__file__).parent / "fixtures"
class TestSingleModuleMapping:
def test_simple_board_connectors(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
connectors = result["connectors"]
assert "amplifier_board" in connectors # module header
assert "J1" in connectors
assert "J2" in connectors
assert "TP1" in connectors
def test_module_header_pinlabels(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
header = result["connectors"]["amplifier_board"]
assert header["pinlabels"] == ["VIN", "GND", "VOUT", "SIGNAL_IN"]
assert header["type"] == "Module Interface"
def test_connector_pinlabels(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
j1 = result["connectors"]["J1"]
assert j1["pinlabels"] == ["VIN", "GND"]
def test_test_point_style(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
tp1 = result["connectors"]["TP1"]
assert tp1.get("style") == "simple"
def test_cables_created(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
cables = result["cables"]
assert len(cables) >= 2 # At least cables for J1 and J2 connections
def test_connections_created(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
connections = result["connections"]
assert len(connections) >= 2
def test_traceability_notes(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
header = result["connectors"]["amplifier_board"]
assert "SPICE subcircuit" in header["notes"]
def test_nonexistent_subcircuit_raises(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
with pytest.raises(ValueError, match="not found"):
map_single_module(netlist, "nonexistent")
def test_metadata_passthrough(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
meta = {"title": "Test Diagram", "author": "Test"}
result = map_single_module(netlist, "amplifier_board", metadata=meta)
assert result["metadata"]["title"] == "Test Diagram"
class TestSingleModuleFiltering:
def test_no_ground_filter(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
config = FilterConfig(show_ground=False)
result = map_single_module(netlist, "amplifier_board", config)
header = result["connectors"]["amplifier_board"]
assert "GND" not in header["pinlabels"]
def test_no_power_filter(self):
"""When power is hidden, VIN should still appear as it's a port name, not a power net."""
netlist = parse_netlist(FIXTURES / "simple_board.net")
config = FilterConfig(show_power=False)
result = map_single_module(netlist, "amplifier_board", config)
# VIN is a port name — it's not VCC/VDD so it stays
header = result["connectors"]["amplifier_board"]
assert "VIN" in header["pinlabels"]
def test_exclude_component_ref(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
config = FilterConfig(exclude_refs=["TP1"])
result = map_single_module(netlist, "amplifier_board", config)
assert "TP1" not in result["connectors"]

124
tests/test_yaml_emitter.py Normal file
View File

@ -0,0 +1,124 @@
"""Tests for the YAML emitter."""
import yaml
from spice2wireviz.emitter.yaml_emitter import (
assemble_wireviz_doc,
build_cable,
build_connection,
build_connector,
emit_yaml,
)
class TestBuildConnector:
def test_minimal_with_pinlabels(self):
conn = build_connector("J1", pinlabels=["A", "B"])
assert conn["pinlabels"] == ["A", "B"]
def test_minimal_with_pincount(self):
conn = build_connector("J1", pincount=4)
assert conn["pincount"] == 4
def test_with_type_and_notes(self):
conn = build_connector("J1", pinlabels=["A"], connector_type="DB9", notes="test")
assert conn["type"] == "DB9"
assert conn["notes"] == "test"
def test_simple_style(self):
conn = build_connector("TP1", pinlabels=["SIG"], style="simple")
assert conn["style"] == "simple"
def test_empty_strings_omitted(self):
conn = build_connector("J1", pinlabels=["A"])
assert "type" not in conn
assert "style" not in conn
assert "notes" not in conn
class TestBuildCable:
def test_with_wirecount(self):
cable = build_cable("W1", wirecount=3)
assert cable["wirecount"] == 3
def test_with_colors(self):
cable = build_cable("W1", colors=["BK", "RD", ""])
assert cable["colors"] == ["BK", "RD", ""]
def test_with_labels(self):
cable = build_cable("W1", wirecount=2, wirelabels=["GND", "VCC"])
assert cable["wirelabels"] == ["GND", "VCC"]
def test_bundle_category(self):
cable = build_cable("W1", wirecount=2, category="bundle")
assert cable["category"] == "bundle"
class TestBuildConnection:
def test_single_wire(self):
conn = build_connection("J1", [1], "W1", [1], "J2", [1])
assert len(conn) == 3
assert conn[0] == {"J1": 1}
assert conn[1] == {"W1": 1}
assert conn[2] == {"J2": 1}
def test_multi_wire(self):
conn = build_connection("J1", [1, 2, 3], "W1", [1, 2, 3], "J2", [1, 2, 3])
assert conn[0] == {"J1": [1, 2, 3]}
assert conn[1] == {"W1": [1, 2, 3]}
assert conn[2] == {"J2": [1, 2, 3]}
class TestAssembleDoc:
def test_minimal_doc(self):
doc = assemble_wireviz_doc(
connectors={"J1": {"pincount": 2}},
cables={"W1": {"wirecount": 1}},
connections=[[{"J1": 1}, {"W1": 1}, {"J2": 1}]],
)
assert "connectors" in doc
assert "cables" in doc
assert "connections" in doc
assert "metadata" not in doc
def test_with_metadata(self):
doc = assemble_wireviz_doc(
connectors={},
cables={},
connections=[],
metadata={"title": "Test"},
)
assert doc["metadata"]["title"] == "Test"
class TestEmitYaml:
def test_produces_valid_yaml(self):
doc = assemble_wireviz_doc(
connectors={"J1": {"pinlabels": ["A", "B"]}, "J2": {"pinlabels": ["C", "D"]}},
cables={"W1": {"wirecount": 2}},
connections=[[{"J1": [1, 2]}, {"W1": [1, 2]}, {"J2": [1, 2]}]],
)
yaml_str = emit_yaml(doc)
parsed = yaml.safe_load(yaml_str)
assert parsed["connectors"]["J1"]["pinlabels"] == ["A", "B"]
def test_deterministic_output(self):
doc = assemble_wireviz_doc(
connectors={"Z": {"pincount": 1}, "A": {"pincount": 1}},
cables={"W1": {"wirecount": 1}},
connections=[],
)
yaml1 = emit_yaml(doc)
yaml2 = emit_yaml(doc)
assert yaml1 == yaml2 # Byte-identical
def test_preserves_insertion_order(self):
doc = assemble_wireviz_doc(
connectors={"Z_conn": {"pincount": 1}, "A_conn": {"pincount": 1}},
cables={},
connections=[],
)
yaml_str = emit_yaml(doc)
z_pos = yaml_str.index("Z_conn")
a_pos = yaml_str.index("A_conn")
assert z_pos < a_pos # Order preserved, not sorted alphabetically