kigit 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kigit/__init__.py +6 -0
- kigit/cli.py +157 -0
- kigit/constants.py +22 -0
- kigit/diff.py +121 -0
- kigit/extractor.py +46 -0
- kigit/file_ops.py +88 -0
- kigit/git_operations.py +31 -0
- kigit/health_checker.py +57 -0
- kigit/logger.py +34 -0
- kigit/message_printer.py +32 -0
- kigit/output_generator.py +243 -0
- kigit/parser.py +61 -0
- kigit/semantic_categorization.py +74 -0
- kigit-0.0.1.dist-info/METADATA +228 -0
- kigit-0.0.1.dist-info/RECORD +17 -0
- kigit-0.0.1.dist-info/WHEEL +4 -0
- kigit-0.0.1.dist-info/entry_points.txt +3 -0
kigit/__init__.py
ADDED
kigit/cli.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Copyright (c) 2026 RUB-SELAB-2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the MIT License.
|
|
4
|
+
# See the LICENSE file in the project root for full license information.
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typer import rich_utils
|
|
10
|
+
|
|
11
|
+
from kigit.extractor import export_netlist
|
|
12
|
+
from kigit.parser import parse_netlist_from_xml
|
|
13
|
+
from kigit.file_ops import find_sch_file, cleanup_temp_data
|
|
14
|
+
from kigit.git_operations import get_reference_file
|
|
15
|
+
from kigit.diff import diff_netlists
|
|
16
|
+
from kigit.output_generator import generate_output
|
|
17
|
+
from kigit.health_checker import verify_dependencies
|
|
18
|
+
from kigit.logger import configure_logger, get_logger
|
|
19
|
+
from kigit.constants import STATUS_MESSAGES, LOG_DIR, SCHEMATICS_FILE_EXTENSION
|
|
20
|
+
from kigit.message_printer import print_message
|
|
21
|
+
import importlib.metadata
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
logger = get_logger()
|
|
25
|
+
|
|
26
|
+
# Override color of help subtext to white.
|
|
27
|
+
rich_utils.STYLE_HELPTEXT = ""
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
rich_markup_mode="rich",
|
|
32
|
+
help="""
|
|
33
|
+
Welcome to kigit :sparkles:
|
|
34
|
+
|
|
35
|
+
Use [bold bright_green]kigit[/bold bright_green] or [bold bright_green]kigit --help[/bold bright_green] for general help and [bold bright_green]kigit COMMAND --help[/bold bright_green] for help on a specific command.
|
|
36
|
+
|
|
37
|
+
:warning: [yellow]Important:[/yellow] In order for this tool to work, committing an initial schematics file to the git repository is expected. An untracked schematics file won't work.
|
|
38
|
+
""",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Runs before each command
|
|
43
|
+
@app.callback()
|
|
44
|
+
def init():
|
|
45
|
+
configure_logger()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command()
|
|
49
|
+
def version():
|
|
50
|
+
"""
|
|
51
|
+
Gives app version.
|
|
52
|
+
"""
|
|
53
|
+
version = importlib.metadata.version("kigit")
|
|
54
|
+
print_message(f"kigit version {version}", type="Override")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command()
|
|
58
|
+
def check_health():
|
|
59
|
+
"""
|
|
60
|
+
Checks if all required dependencies to run kigit are present on the system.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
verify_dependencies(silent=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def diff(
|
|
68
|
+
c1_ref: Annotated[
|
|
69
|
+
str,
|
|
70
|
+
typer.Option(
|
|
71
|
+
"--c1-ref", "-c1", help="Hash of reference commit for comparison."
|
|
72
|
+
),
|
|
73
|
+
] = "HEAD",
|
|
74
|
+
c2_ref: Annotated[
|
|
75
|
+
str | None,
|
|
76
|
+
typer.Option(
|
|
77
|
+
"--c2-ref",
|
|
78
|
+
"-c2",
|
|
79
|
+
help="Hash of reference commit for comparison with the first reference commit.",
|
|
80
|
+
),
|
|
81
|
+
] = None,
|
|
82
|
+
search_depth: Annotated[
|
|
83
|
+
int,
|
|
84
|
+
typer.Option(
|
|
85
|
+
"--search-depth",
|
|
86
|
+
"-p",
|
|
87
|
+
help="Search depth when looking up the schematics file.",
|
|
88
|
+
),
|
|
89
|
+
] = 2,
|
|
90
|
+
search_directory: Annotated[
|
|
91
|
+
Path,
|
|
92
|
+
typer.Option(
|
|
93
|
+
"--search-directory", "-d", help="Root path for schematics file lookup."
|
|
94
|
+
),
|
|
95
|
+
] = Path("."),
|
|
96
|
+
verbose: Annotated[
|
|
97
|
+
bool,
|
|
98
|
+
typer.Option("--verbose", "-v", help="Shows a more detailed diffing output."),
|
|
99
|
+
] = False,
|
|
100
|
+
):
|
|
101
|
+
"""
|
|
102
|
+
Summarizes differences between two KiCad project states in a structured and understandable way, filtering out noise from formatting, ordering, and tool-generated metadata.
|
|
103
|
+
|
|
104
|
+
[deep_sky_blue1]Remarks:[/deep_sky_blue1]
|
|
105
|
+
When omitting the second reference commit, the comparison will be done between the first reference commit (default: HEAD) and the file in the working directory.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
result = verify_dependencies()
|
|
110
|
+
|
|
111
|
+
if not result:
|
|
112
|
+
raise typer.Exit(code=1)
|
|
113
|
+
|
|
114
|
+
schematics_file_path = find_sch_file(
|
|
115
|
+
search_directory, SCHEMATICS_FILE_EXTENSION, search_depth
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if schematics_file_path is None:
|
|
119
|
+
print_message(
|
|
120
|
+
formatted_message=STATUS_MESSAGES[
|
|
121
|
+
"NO_SCHEMATICS_FILE_FOUND_MESSAGE"
|
|
122
|
+
].format(search_directory=search_directory),
|
|
123
|
+
type="Error",
|
|
124
|
+
)
|
|
125
|
+
raise typer.Exit(code=1)
|
|
126
|
+
|
|
127
|
+
c1_sch_path = get_reference_file(c1_ref, schematics_file_path)
|
|
128
|
+
c1_netlist_xml = export_netlist(c1_sch_path)
|
|
129
|
+
c1_netlist = parse_netlist_from_xml(c1_netlist_xml)
|
|
130
|
+
|
|
131
|
+
if c2_ref is None:
|
|
132
|
+
c2_sch_path = schematics_file_path
|
|
133
|
+
else:
|
|
134
|
+
c2_sch_path = get_reference_file(c2_ref, schematics_file_path)
|
|
135
|
+
|
|
136
|
+
c2_netlist_xml = export_netlist(c2_sch_path)
|
|
137
|
+
c2_netlist = parse_netlist_from_xml(c2_netlist_xml)
|
|
138
|
+
|
|
139
|
+
netlist_diff = diff_netlists(c1_netlist, c2_netlist)
|
|
140
|
+
|
|
141
|
+
print_message(generate_output(netlist_diff, verbose), type="Override")
|
|
142
|
+
except typer.Exit as e:
|
|
143
|
+
sys.exit(e.exit_code)
|
|
144
|
+
except Exception as x:
|
|
145
|
+
logger.exception(x)
|
|
146
|
+
print_message(
|
|
147
|
+
formatted_message=STATUS_MESSAGES["GENERAL_EXCEPTION_MESSAGE"].format(
|
|
148
|
+
log_dir=LOG_DIR
|
|
149
|
+
),
|
|
150
|
+
type="Error",
|
|
151
|
+
)
|
|
152
|
+
finally:
|
|
153
|
+
cleanup_temp_data()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
app()
|
kigit/constants.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Copyright (c) 2026 RUB-SELAB-2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the MIT License.
|
|
4
|
+
# See the LICENSE file in the project root for full license information.
|
|
5
|
+
|
|
6
|
+
SCHEMATICS_FILE_EXTENSION = ".kicad_sch"
|
|
7
|
+
PROJECT_FILE_EXTENSION = ".kicad_pro"
|
|
8
|
+
IGNORED_LOOKUP_PATHS = [".git", ".history", "kigit-log"]
|
|
9
|
+
NETLIST_TEMP_DIR = ".kigit_netlist_temp"
|
|
10
|
+
SUBDIR_FOR_DIFFS = ".kigit_temp"
|
|
11
|
+
REF_SCH_FILENAME = "schematic.kicad_sch"
|
|
12
|
+
LOGGER_KEY = "c_logger"
|
|
13
|
+
LOG_DIR = "kigit-log"
|
|
14
|
+
LOG_FILE_NAME = "kigit.log"
|
|
15
|
+
|
|
16
|
+
STATUS_MESSAGES = {
|
|
17
|
+
"NO_SCHEMATICS_FILE_FOUND_MESSAGE": "No schematics file (.kicad_sch) has been found with the current parameters. Try adjusting the search depth if the file is nested.\nRoot path used: '{search_directory}'",
|
|
18
|
+
"GENERAL_EXCEPTION_MESSAGE": "App has exited with an exception. See the '{log_dir}' folder for app logs.",
|
|
19
|
+
"GIT_NOT_FOUND_MESSAGE": "Couldn't find Git on your system. Git is required for this tool to work.",
|
|
20
|
+
"KICAD_NOT_FOUND_MESSAGE": "Couldn't find KiCad on your system. KiCad is required for this tool to work.",
|
|
21
|
+
"NO_CHANGES_TO_SHOW_MESSAGE": "No semantically meaningful changes to show.\n\n (Hint: only added, removed, changed and renamed items are shown (i.e. code changes are discarded.)",
|
|
22
|
+
}
|
kigit/diff.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Copyright (c) 2026 RUB-SELAB-2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the MIT License.
|
|
4
|
+
# See the LICENSE file in the project root for full license information.
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from kigit.parser import Net, Node
|
|
8
|
+
from kigit.semantic_categorization import classify_change_impact
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class NetRename:
|
|
13
|
+
old: Net
|
|
14
|
+
new: Net
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class NetDiff:
|
|
19
|
+
new: Net
|
|
20
|
+
old: Net
|
|
21
|
+
impact_tags: tuple[str, ...] = ()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class NetlistDiff:
|
|
26
|
+
added: frozenset[Net]
|
|
27
|
+
removed: frozenset[Net]
|
|
28
|
+
changed: frozenset[NetDiff]
|
|
29
|
+
renamed: frozenset[NetRename]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _match_renames(removed, added):
|
|
33
|
+
candidates = [
|
|
34
|
+
(r, a)
|
|
35
|
+
for r in removed
|
|
36
|
+
for a in added
|
|
37
|
+
if r.nodes and set(r.nodes) == set(a.nodes)
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
candidates.sort(key=lambda pair: (pair[0].name, pair[1].name))
|
|
41
|
+
|
|
42
|
+
used_old, used_new, renames = set(), set(), []
|
|
43
|
+
for r, a in candidates:
|
|
44
|
+
if r.name in used_old or a.name in used_new:
|
|
45
|
+
continue
|
|
46
|
+
renames.append(NetRename(old=r, new=a))
|
|
47
|
+
used_old.add(r.name)
|
|
48
|
+
used_new.add(a.name)
|
|
49
|
+
|
|
50
|
+
leftover_removed = frozenset(r for r in removed if r.name not in used_old)
|
|
51
|
+
leftover_added = frozenset(a for a in added if a.name not in used_new)
|
|
52
|
+
return frozenset(renames), leftover_removed, leftover_added
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def diff_netlists(old, new):
|
|
56
|
+
old_nets = {n.name: n for n in old.nets if not _is_unconnected(n)}
|
|
57
|
+
new_nets = {n.name: n for n in new.nets if not _is_unconnected(n)}
|
|
58
|
+
|
|
59
|
+
added = frozenset(new_nets[name] for name in new_nets.keys() - old_nets.keys())
|
|
60
|
+
removed = frozenset(old_nets[name] for name in old_nets.keys() - new_nets.keys())
|
|
61
|
+
|
|
62
|
+
renamed, removed, added = _match_renames(removed, added)
|
|
63
|
+
|
|
64
|
+
changed = set()
|
|
65
|
+
for name in old_nets.keys() & new_nets.keys():
|
|
66
|
+
old_net = old_nets[name]
|
|
67
|
+
new_net = new_nets[name]
|
|
68
|
+
|
|
69
|
+
changes = get_net_changes(old_net, new_net)
|
|
70
|
+
if not changes:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
impact_tags = tuple(classify_change_impact(old_net, new_net, changes))
|
|
74
|
+
changed.add(NetDiff(old=old_net, new=new_net, impact_tags=impact_tags))
|
|
75
|
+
|
|
76
|
+
return NetlistDiff(
|
|
77
|
+
added=added, removed=removed, changed=frozenset(changed), renamed=renamed
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_unconnected(net):
|
|
82
|
+
return net.name.startswith("unconnected-")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_net_changes(old: Net, new: Net) -> dict | None:
|
|
86
|
+
if old == new:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
changes = {}
|
|
90
|
+
|
|
91
|
+
if old.name != new.name:
|
|
92
|
+
changes["name"] = {"old": old.name, "new": new.name}
|
|
93
|
+
|
|
94
|
+
if old.net_class != new.net_class:
|
|
95
|
+
changes["net_class"] = {"old": old.net_class, "new": new.net_class}
|
|
96
|
+
|
|
97
|
+
if old.nodes != new.nodes:
|
|
98
|
+
changes["nodes"] = compare_node_lists(old.nodes, new.nodes)
|
|
99
|
+
|
|
100
|
+
return changes
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def compare_node_lists(
|
|
104
|
+
old_nodes: tuple[Node, ...], new_nodes: tuple[Node, ...]
|
|
105
|
+
) -> dict:
|
|
106
|
+
|
|
107
|
+
# Assuming n.ref, n.pin can uniquely identify a node.
|
|
108
|
+
# TODO: Might change later if criteria isn't enough
|
|
109
|
+
|
|
110
|
+
old_dict = {(n.ref, n.pin): n for n in old_nodes}
|
|
111
|
+
new_dict = {(n.ref, n.pin): n for n in new_nodes}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
"added": [new_dict[key] for key in new_dict.keys() - old_dict.keys()],
|
|
115
|
+
"removed": [old_dict[key] for key in old_dict.keys() - new_dict.keys()],
|
|
116
|
+
"changed": [
|
|
117
|
+
{"old": old_dict[key], "new": new_dict[key]}
|
|
118
|
+
for key in old_dict.keys() & new_dict.keys()
|
|
119
|
+
if old_dict[key] != new_dict[key]
|
|
120
|
+
],
|
|
121
|
+
}
|
kigit/extractor.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Copyright (c) 2026 RUB-SELAB-2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the MIT License.
|
|
4
|
+
# See the LICENSE file in the project root for full license information.
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from kigit.constants import NETLIST_TEMP_DIR
|
|
9
|
+
|
|
10
|
+
KICAD_CLI = "kicad-cli"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def export_netlist(sch_path: Path) -> str:
|
|
14
|
+
"""Runs `kicad-cli sch export netlist --format kicadxml` and returns the unparsed xml as str."""
|
|
15
|
+
|
|
16
|
+
if not sch_path.is_file():
|
|
17
|
+
raise FileNotFoundError(sch_path)
|
|
18
|
+
|
|
19
|
+
ouput_path = Path(NETLIST_TEMP_DIR) / "netlist.xml"
|
|
20
|
+
ouput_path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
subprocess.run(
|
|
24
|
+
[
|
|
25
|
+
"kicad-cli",
|
|
26
|
+
"sch",
|
|
27
|
+
"export",
|
|
28
|
+
"netlist",
|
|
29
|
+
"--format",
|
|
30
|
+
"kicadxml",
|
|
31
|
+
"--output",
|
|
32
|
+
ouput_path,
|
|
33
|
+
sch_path.as_posix(),
|
|
34
|
+
],
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
encoding="utf-8",
|
|
38
|
+
check=True,
|
|
39
|
+
).stdout
|
|
40
|
+
|
|
41
|
+
with open(ouput_path) as file:
|
|
42
|
+
return file.read()
|
|
43
|
+
except subprocess.CalledProcessError as e:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"Got an invalid response when calling kicad-cli:\n Error:\n {e.stderr}"
|
|
46
|
+
)
|
kigit/file_ops.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Copyright (c) 2026 RUB-SELAB-2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the MIT License.
|
|
4
|
+
# See the LICENSE file in the project root for full license information.
|
|
5
|
+
|
|
6
|
+
from os import walk
|
|
7
|
+
from os.path import join
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from shutil import rmtree
|
|
10
|
+
from kigit.constants import (
|
|
11
|
+
PROJECT_FILE_EXTENSION,
|
|
12
|
+
SUBDIR_FOR_DIFFS,
|
|
13
|
+
NETLIST_TEMP_DIR,
|
|
14
|
+
IGNORED_LOOKUP_PATHS,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _find_first_file(root_path: Path, extension: str, depth: int) -> Path | None:
|
|
19
|
+
"""Searches the current directory with the provided depth for the first file that matches the provided extension"""
|
|
20
|
+
|
|
21
|
+
if not extension.startswith("."):
|
|
22
|
+
extension = "." + extension
|
|
23
|
+
|
|
24
|
+
for dirpath, dirs, files in walk(root_path):
|
|
25
|
+
current_dir = Path(dirpath).relative_to(root_path)
|
|
26
|
+
|
|
27
|
+
current_depth = len(current_dir.parts)
|
|
28
|
+
if current_depth > depth:
|
|
29
|
+
dirs.clear()
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
dirs[:] = [d for d in dirs if not should_ignore_path(current_dir / d)]
|
|
33
|
+
|
|
34
|
+
for file in files:
|
|
35
|
+
if file.endswith(extension):
|
|
36
|
+
return Path(join(dirpath, file))
|
|
37
|
+
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_sch_file(root_path: Path, extension: str, depth: int) -> Path | None:
|
|
42
|
+
"""Searches the current directory with the provided depth for the first file that matches the provided extension"""
|
|
43
|
+
|
|
44
|
+
pro_file_path: Path | None = _find_first_file(
|
|
45
|
+
root_path=root_path, extension=PROJECT_FILE_EXTENSION, depth=depth
|
|
46
|
+
)
|
|
47
|
+
if pro_file_path is None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
if not extension.startswith("."):
|
|
51
|
+
extension = "." + extension
|
|
52
|
+
|
|
53
|
+
for dirpath, dirs, files in walk(root_path):
|
|
54
|
+
current_dir = Path(dirpath).relative_to(root_path)
|
|
55
|
+
|
|
56
|
+
current_depth = len(current_dir.parts)
|
|
57
|
+
if current_depth > depth:
|
|
58
|
+
dirs.clear()
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
dirs[:] = [d for d in dirs if not should_ignore_path(current_dir / d)]
|
|
62
|
+
|
|
63
|
+
for file in files:
|
|
64
|
+
if file.endswith(extension) and Path(file).stem == pro_file_path.stem:
|
|
65
|
+
return Path(join(dirpath, file))
|
|
66
|
+
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def should_ignore_path(path: Path) -> bool:
|
|
71
|
+
for ignored in IGNORED_LOOKUP_PATHS:
|
|
72
|
+
ignored_path = Path(ignored)
|
|
73
|
+
try:
|
|
74
|
+
path.relative_to(ignored_path)
|
|
75
|
+
return True
|
|
76
|
+
except ValueError:
|
|
77
|
+
# Not relative, continue
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cleanup_temp_data():
|
|
84
|
+
if Path.exists(Path(SUBDIR_FOR_DIFFS)):
|
|
85
|
+
rmtree(SUBDIR_FOR_DIFFS, ignore_errors=True)
|
|
86
|
+
|
|
87
|
+
if Path.exists(Path(NETLIST_TEMP_DIR)):
|
|
88
|
+
rmtree(NETLIST_TEMP_DIR, ignore_errors=True)
|
kigit/git_operations.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Copyright (c) 2026 RUB-SELAB-2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the MIT License.
|
|
4
|
+
# See the LICENSE file in the project root for full license information.
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from kigit.constants import SUBDIR_FOR_DIFFS, REF_SCH_FILENAME
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_reference_file(ref: str, file_path: Path) -> Path:
|
|
13
|
+
out_file_path = Path(SUBDIR_FOR_DIFFS) / REF_SCH_FILENAME
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
result = subprocess.run(
|
|
17
|
+
["git", "show", f"{ref}:{Path(file_path).as_posix()}"],
|
|
18
|
+
text=True,
|
|
19
|
+
check=True,
|
|
20
|
+
capture_output=True,
|
|
21
|
+
encoding="utf-8",
|
|
22
|
+
)
|
|
23
|
+
except subprocess.CalledProcessError as e:
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
f"Failed to retrieve file '{file_path}' from git ref '{ref}': {e.stderr}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
out_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
out_file_path.write_text(result.stdout, encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
return out_file_path
|
kigit/health_checker.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Copyright (c) 2026 RUB-SELAB-2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the MIT License.
|
|
4
|
+
# See the LICENSE file in the project root for full license information.
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
from kigit.constants import STATUS_MESSAGES
|
|
8
|
+
from kigit.message_printer import print_message
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def check_git() -> bool:
|
|
12
|
+
try:
|
|
13
|
+
subprocess.run(
|
|
14
|
+
["git", "--version"],
|
|
15
|
+
capture_output=True,
|
|
16
|
+
check=True,
|
|
17
|
+
text=True,
|
|
18
|
+
encoding="utf-8",
|
|
19
|
+
)
|
|
20
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_kicad() -> bool:
|
|
27
|
+
try:
|
|
28
|
+
subprocess.run(
|
|
29
|
+
["kicad-cli", "--version"],
|
|
30
|
+
capture_output=True,
|
|
31
|
+
check=True,
|
|
32
|
+
text=True,
|
|
33
|
+
encoding="utf-8",
|
|
34
|
+
)
|
|
35
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def verify_dependencies(silent: bool = True) -> bool:
|
|
42
|
+
has_git = check_git()
|
|
43
|
+
has_kicad = check_kicad()
|
|
44
|
+
has_errors = False
|
|
45
|
+
|
|
46
|
+
if not has_git:
|
|
47
|
+
print_message(STATUS_MESSAGES["GIT_NOT_FOUND_MESSAGE"], type="Error")
|
|
48
|
+
has_errors = True
|
|
49
|
+
|
|
50
|
+
if not has_kicad:
|
|
51
|
+
print_message(STATUS_MESSAGES["KICAD_NOT_FOUND_MESSAGE"], type="Error")
|
|
52
|
+
has_errors = True
|
|
53
|
+
|
|
54
|
+
if not silent and not has_errors:
|
|
55
|
+
print_message("All dependencies OK. Ready to use kigit.", type="Success")
|
|
56
|
+
|
|
57
|
+
return not has_errors
|
kigit/logger.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Copyright (c) 2026 RUB-SELAB-2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the MIT License.
|
|
4
|
+
# See the LICENSE file in the project root for full license information.
|
|
5
|
+
|
|
6
|
+
from kigit.constants import LOG_DIR, LOGGER_KEY, LOG_FILE_NAME
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_logger():
|
|
13
|
+
return logging.getLogger(LOGGER_KEY)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def ensure_log_path_exists():
|
|
17
|
+
path = Path(".") / LOG_DIR
|
|
18
|
+
|
|
19
|
+
if not path.exists():
|
|
20
|
+
path.mkdir(exist_ok=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def configure_logger():
|
|
24
|
+
ensure_log_path_exists()
|
|
25
|
+
logger = get_logger()
|
|
26
|
+
|
|
27
|
+
# Remove all present handlers to avoid duplicate logging messages
|
|
28
|
+
logger.handlers.clear()
|
|
29
|
+
|
|
30
|
+
path = Path(".") / LOG_DIR / LOG_FILE_NAME
|
|
31
|
+
logging.basicConfig(
|
|
32
|
+
format="\n[%(levelname)s]\nDate: %(asctime)s\nFile: (%(filename)s)\nLine: %(lineno)d\nFunction: %(funcName)s\nLog Message: %(message)s\n",
|
|
33
|
+
handlers=[logging.FileHandler(Path(path))],
|
|
34
|
+
)
|
kigit/message_printer.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Copyright (c) 2026 RUB-SELAB-2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the MIT License.
|
|
4
|
+
# See the LICENSE file in the project root for full license information.
|
|
5
|
+
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def print_message(
|
|
12
|
+
formatted_message: str,
|
|
13
|
+
type: Literal[
|
|
14
|
+
"Error", "Success", "Warning", "Information", "Override"
|
|
15
|
+
] = "Override",
|
|
16
|
+
):
|
|
17
|
+
if type == "Error":
|
|
18
|
+
color = typer.colors.BRIGHT_RED
|
|
19
|
+
elif type == "Warning":
|
|
20
|
+
color = typer.colors.BRIGHT_YELLOW
|
|
21
|
+
elif type == "Success":
|
|
22
|
+
color = typer.colors.BRIGHT_GREEN
|
|
23
|
+
elif type == "Information":
|
|
24
|
+
color = typer.colors.BRIGHT_BLUE
|
|
25
|
+
else:
|
|
26
|
+
color = typer.colors.WHITE
|
|
27
|
+
|
|
28
|
+
if type == "Override":
|
|
29
|
+
# Use the colors that are specified in the message
|
|
30
|
+
typer.echo(typer.style(formatted_message))
|
|
31
|
+
else:
|
|
32
|
+
typer.echo(typer.style(formatted_message, color), err=type == "Error")
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Copyright (c) 2026 RUB-SELAB-2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the MIT License.
|
|
4
|
+
# See the LICENSE file in the project root for full license information.
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from kigit.parser import Node, Net
|
|
9
|
+
from kigit.diff import NetRename, NetlistDiff, get_net_changes, NetDiff
|
|
10
|
+
from kigit.constants import STATUS_MESSAGES
|
|
11
|
+
from kigit.semantic_categorization import classify_net_properties
|
|
12
|
+
|
|
13
|
+
SECTION_SEPARATOR = "========================================="
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def format_pin_function(pin_function: Optional[str]) -> str:
|
|
17
|
+
"""Extract and format pin function from KiCad format."""
|
|
18
|
+
if not pin_function:
|
|
19
|
+
return ""
|
|
20
|
+
|
|
21
|
+
base_function = (
|
|
22
|
+
pin_function[: pin_function.rfind("_")] if "_" in pin_function else pin_function
|
|
23
|
+
)
|
|
24
|
+
return f"with {base_function} function "
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def format_node(node: Node, prefix: str = "") -> str:
|
|
28
|
+
"""Format a single node for display."""
|
|
29
|
+
pin_func = format_pin_function(node.pin_function)
|
|
30
|
+
return f"{prefix}Element {node.ref} on pin {node.pin} {pin_func}and {node.pintype} type."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_net_nodes(nodes: tuple[Node, ...], prefix: str = "") -> str:
|
|
34
|
+
"""Format all nodes in a net."""
|
|
35
|
+
return "\n".join(format_node(node, prefix) for node in nodes)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def generate_net_section(nets: frozenset[Net], section_type: str) -> str:
|
|
39
|
+
if not nets:
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
color_map = {
|
|
43
|
+
"added": typer.colors.GREEN,
|
|
44
|
+
"removed": typer.colors.RED,
|
|
45
|
+
}
|
|
46
|
+
color = color_map.get(section_type, typer.colors.WHITE)
|
|
47
|
+
|
|
48
|
+
lines = []
|
|
49
|
+
for net in nets:
|
|
50
|
+
lines.append(typer.style(f"\tNet '{net.name}':", fg=color))
|
|
51
|
+
for node in net.nodes:
|
|
52
|
+
prefix = "+ " if section_type == "added" else "- "
|
|
53
|
+
lines.append(f"\t\t{prefix}{format_node(node)}")
|
|
54
|
+
|
|
55
|
+
header = typer.style(f"{section_type.title()} Nets:\n", fg=color, bold=True)
|
|
56
|
+
body = "\n".join(lines) + "\n"
|
|
57
|
+
return header + body + "\n" + SECTION_SEPARATOR + "\n\n"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def format_node_change(node_info: dict) -> str:
|
|
61
|
+
"""Format a single changed node."""
|
|
62
|
+
old, new = node_info["old"], node_info["new"]
|
|
63
|
+
changes = []
|
|
64
|
+
|
|
65
|
+
if old.ref != new.ref:
|
|
66
|
+
changes.append(f"ref {old.ref} → {new.ref}")
|
|
67
|
+
if old.pin != new.pin:
|
|
68
|
+
changes.append(f"pin {old.pin} → {new.pin}")
|
|
69
|
+
if old.pin_function != new.pin_function:
|
|
70
|
+
changes.append(f"pin_function {old.pin_function} → {new.pin_function}")
|
|
71
|
+
if old.pintype != new.pintype:
|
|
72
|
+
changes.append(f"pintype {old.pintype} → {new.pintype}")
|
|
73
|
+
|
|
74
|
+
return ", ".join(changes) if changes else "no significant changes"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def generate_node_changes(node_changes: dict) -> str:
|
|
78
|
+
"""Generate output for node changes (added, removed, changed)."""
|
|
79
|
+
lines = []
|
|
80
|
+
|
|
81
|
+
if node_changes.get("added"):
|
|
82
|
+
lines.append("\t\tAdded nodes:")
|
|
83
|
+
for node in node_changes["added"]:
|
|
84
|
+
lines.append(
|
|
85
|
+
typer.style(
|
|
86
|
+
f"\t\t\t+ {node.ref} pin {node.pin} ({node.pin_function or 'no function'}, {node.pintype})",
|
|
87
|
+
fg=typer.colors.GREEN,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if node_changes.get("changed"):
|
|
92
|
+
lines.append("\t\tChanged nodes:")
|
|
93
|
+
for change in node_changes["changed"]:
|
|
94
|
+
lines.append(
|
|
95
|
+
typer.style(
|
|
96
|
+
f"\t\t\t* {format_node_change(change)}", fg=typer.colors.YELLOW
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if node_changes.get("removed"):
|
|
101
|
+
lines.append("\t\tRemoved nodes:")
|
|
102
|
+
for node in node_changes["removed"]:
|
|
103
|
+
lines.append(
|
|
104
|
+
typer.style(
|
|
105
|
+
f"\t\t\t- {node.ref} pin {node.pin} ({node.pin_function or 'no function'}, {node.pintype})",
|
|
106
|
+
fg=typer.colors.RED,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def generate_changed_section(changed_diffs: frozenset[NetDiff]) -> str:
|
|
114
|
+
"""Generate output for changed nets."""
|
|
115
|
+
if not changed_diffs:
|
|
116
|
+
return ""
|
|
117
|
+
|
|
118
|
+
lines = [typer.style("Changed Nets:", fg=typer.colors.YELLOW, bold=True)]
|
|
119
|
+
|
|
120
|
+
for net_diff in sorted(changed_diffs, key=lambda nd: nd.old.name):
|
|
121
|
+
old_net = net_diff.old
|
|
122
|
+
new_net = net_diff.new
|
|
123
|
+
|
|
124
|
+
changes = get_net_changes(old_net, new_net)
|
|
125
|
+
if not changes:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
lines.append(
|
|
129
|
+
typer.style(
|
|
130
|
+
f"\tNet '{old_net.name}':",
|
|
131
|
+
fg=typer.colors.YELLOW,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
properties = classify_net_properties(new_net)
|
|
136
|
+
if properties:
|
|
137
|
+
lines.append(f"\t\t* properties: {', '.join(properties)}")
|
|
138
|
+
|
|
139
|
+
if net_diff.impact_tags:
|
|
140
|
+
lines.append(
|
|
141
|
+
typer.style(
|
|
142
|
+
f"\t\t* impact: {', '.join(net_diff.impact_tags)}",
|
|
143
|
+
fg=typer.colors.CYAN,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
for field_name, change_info in changes.items():
|
|
148
|
+
if field_name == "nodes":
|
|
149
|
+
lines.append(generate_node_changes(change_info))
|
|
150
|
+
else:
|
|
151
|
+
lines.append(
|
|
152
|
+
f"\t\t* {field_name}: {change_info['old']} → {change_info['new']}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
body = "\n".join(lines) + "\n"
|
|
156
|
+
|
|
157
|
+
return body + "\n" + SECTION_SEPARATOR + "\n"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def generate_renamed_section(net_renames: frozenset[NetRename]) -> str:
|
|
161
|
+
"""Generate output for changed nets."""
|
|
162
|
+
if not net_renames:
|
|
163
|
+
return ""
|
|
164
|
+
|
|
165
|
+
lines = [typer.style("Renamed Nets:", fg=typer.colors.BRIGHT_MAGENTA, bold=True)]
|
|
166
|
+
|
|
167
|
+
for net_rename in net_renames:
|
|
168
|
+
old_net = net_rename.old
|
|
169
|
+
new_net = net_rename.new
|
|
170
|
+
|
|
171
|
+
lines.append(
|
|
172
|
+
typer.style(
|
|
173
|
+
f"\tNet renamed: '{old_net.name}' -> '{new_net.name}'", # dont need (code: {old_net.code}) - Ilya
|
|
174
|
+
typer.colors.BRIGHT_MAGENTA,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
body = "\n".join(lines) + "\n"
|
|
179
|
+
return body + "\n" + SECTION_SEPARATOR + "\n"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def generate_output(netlist_diff: NetlistDiff, verbose: bool) -> str:
|
|
183
|
+
"""Generate human-readable output from netlist differences."""
|
|
184
|
+
|
|
185
|
+
if not verbose:
|
|
186
|
+
return _generate_brief_output(netlist_diff)
|
|
187
|
+
|
|
188
|
+
sections = []
|
|
189
|
+
|
|
190
|
+
added_section = generate_net_section(netlist_diff.added, "added")
|
|
191
|
+
removed_section = generate_net_section(netlist_diff.removed, "removed")
|
|
192
|
+
changed_section = generate_changed_section(netlist_diff.changed)
|
|
193
|
+
renamed_secion = generate_renamed_section(netlist_diff.renamed)
|
|
194
|
+
|
|
195
|
+
if added_section:
|
|
196
|
+
sections.append(added_section)
|
|
197
|
+
if removed_section:
|
|
198
|
+
sections.append(removed_section)
|
|
199
|
+
if changed_section:
|
|
200
|
+
sections.append(changed_section)
|
|
201
|
+
if renamed_secion:
|
|
202
|
+
sections.append(renamed_secion)
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
"".join(sections) if sections else STATUS_MESSAGES["NO_CHANGES_TO_SHOW_MESSAGE"]
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _generate_brief_output(netlist_diff: NetlistDiff) -> str:
|
|
210
|
+
lines = []
|
|
211
|
+
if netlist_diff.added:
|
|
212
|
+
lines.append(
|
|
213
|
+
typer.style(
|
|
214
|
+
f"+ {len(netlist_diff.added)} net(s) added",
|
|
215
|
+
fg=typer.colors.GREEN,
|
|
216
|
+
bold=True,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
if netlist_diff.removed:
|
|
220
|
+
lines.append(
|
|
221
|
+
typer.style(
|
|
222
|
+
f"- {len(netlist_diff.removed)} net(s) removed",
|
|
223
|
+
fg=typer.colors.RED,
|
|
224
|
+
bold=True,
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
if netlist_diff.changed:
|
|
228
|
+
lines.append(
|
|
229
|
+
typer.style(
|
|
230
|
+
f"~ {len(netlist_diff.changed)} net(s) changed",
|
|
231
|
+
fg=typer.colors.YELLOW,
|
|
232
|
+
bold=True,
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
if netlist_diff.renamed:
|
|
236
|
+
lines.append(
|
|
237
|
+
typer.style(
|
|
238
|
+
f"~ {len(netlist_diff.renamed)} net(s) renamed",
|
|
239
|
+
fg=typer.colors.BRIGHT_MAGENTA,
|
|
240
|
+
bold=True,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
return "\n".join(lines) if lines else STATUS_MESSAGES["NO_CHANGES_TO_SHOW_MESSAGE"]
|
kigit/parser.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Copyright (c) 2026 RUB-SELAB-2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the MIT License.
|
|
4
|
+
# See the LICENSE file in the project root for full license information.
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Optional
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Node:
|
|
13
|
+
ref: str
|
|
14
|
+
pin: str
|
|
15
|
+
pin_function: Optional[str]
|
|
16
|
+
pintype: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Net:
|
|
21
|
+
code: str = field(compare=False)
|
|
22
|
+
name: str
|
|
23
|
+
net_class: str
|
|
24
|
+
nodes: tuple["Node", ...]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Netlist:
|
|
29
|
+
nets: tuple["Net", ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_netlist_from_xml(xml: str) -> Netlist:
|
|
33
|
+
root = ET.fromstring(xml)
|
|
34
|
+
|
|
35
|
+
nets_tag = root.find("nets")
|
|
36
|
+
if nets_tag is None:
|
|
37
|
+
raise ValueError("No <nets> tag found in netlist XML.")
|
|
38
|
+
|
|
39
|
+
nets = []
|
|
40
|
+
for net_el in nets_tag.findall("net"):
|
|
41
|
+
a = net_el.attrib
|
|
42
|
+
code = a.get("code") or ""
|
|
43
|
+
nodes = tuple(
|
|
44
|
+
Node(
|
|
45
|
+
ref=ne.attrib.get("ref") or "",
|
|
46
|
+
pin=ne.attrib.get("pin") or "",
|
|
47
|
+
pin_function=ne.attrib.get("pinfunction"),
|
|
48
|
+
pintype=ne.attrib.get("pintype") or "",
|
|
49
|
+
)
|
|
50
|
+
for ne in net_el.findall("node")
|
|
51
|
+
)
|
|
52
|
+
nets.append(
|
|
53
|
+
Net(
|
|
54
|
+
code=code,
|
|
55
|
+
name=a.get("name") or f"<net {code}>",
|
|
56
|
+
net_class=a.get("class") or "",
|
|
57
|
+
nodes=nodes,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return Netlist(nets=tuple(nets))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Copyright (c) 2026 RUB-SELAB-2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the MIT License.
|
|
4
|
+
# See the LICENSE file in the project root for full license information.
|
|
5
|
+
|
|
6
|
+
from kigit.parser import Net
|
|
7
|
+
|
|
8
|
+
CRITICAL_NET_NAMES = {"GND", "VCC", "+3V3", "+5V", "+24V", "VIN"}
|
|
9
|
+
POWER_NAME_FRAGMENTS = ("VCC", "3V3", "5V", "24V", "VIN")
|
|
10
|
+
HIGH_SPEED_KEYWORDS = {"CLK", "CLOCK", "SPI", "USB", "I2C", "UART", "TX", "RX"}
|
|
11
|
+
|
|
12
|
+
MEDIUM_CONNECTIVITY_THRESHOLD = 5
|
|
13
|
+
HIGH_CONNECTIVITY_THRESHOLD = 10
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_power_net(name: str) -> bool:
|
|
17
|
+
return any(fragment in name for fragment in POWER_NAME_FRAGMENTS)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_critical_net(name: str) -> bool:
|
|
21
|
+
return name in CRITICAL_NET_NAMES or _is_power_net(name)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def classify_net_properties(net: Net) -> list[str]:
|
|
25
|
+
"""Describe the *nature* of a net (power, ground, high-speed, ...)."""
|
|
26
|
+
tags: list[str] = []
|
|
27
|
+
name = net.name.upper()
|
|
28
|
+
|
|
29
|
+
if _is_critical_net(name):
|
|
30
|
+
tags.append("critical-net")
|
|
31
|
+
if _is_power_net(name):
|
|
32
|
+
tags.append("power-net")
|
|
33
|
+
if "GND" in name:
|
|
34
|
+
tags.append("ground-net")
|
|
35
|
+
if any(keyword in name for keyword in HIGH_SPEED_KEYWORDS):
|
|
36
|
+
tags.append("high-speed-net")
|
|
37
|
+
|
|
38
|
+
node_count = len(net.nodes)
|
|
39
|
+
if node_count > HIGH_CONNECTIVITY_THRESHOLD:
|
|
40
|
+
tags.append("high-connectivity")
|
|
41
|
+
elif node_count > MEDIUM_CONNECTIVITY_THRESHOLD:
|
|
42
|
+
tags.append("medium-connectivity")
|
|
43
|
+
if node_count == 0:
|
|
44
|
+
tags.append("empty-net")
|
|
45
|
+
|
|
46
|
+
return tags
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def classify_change_impact(old: Net, new: Net, net_changes: dict | None) -> list[str]:
|
|
50
|
+
"""Describe the *meaning* of a change."""
|
|
51
|
+
tags: list[str] = []
|
|
52
|
+
|
|
53
|
+
if _is_critical_net(old.name.upper()) or _is_critical_net(new.name.upper()):
|
|
54
|
+
tags.append("critical-net-change")
|
|
55
|
+
|
|
56
|
+
if len(new.nodes) > len(old.nodes):
|
|
57
|
+
tags.append("connectivity-increased")
|
|
58
|
+
elif len(new.nodes) < len(old.nodes):
|
|
59
|
+
tags.append("connectivity-decreased")
|
|
60
|
+
|
|
61
|
+
if net_changes and "nodes" in net_changes:
|
|
62
|
+
nodes_diff = net_changes["nodes"]
|
|
63
|
+
if nodes_diff.get("added"):
|
|
64
|
+
tags.append("node-added")
|
|
65
|
+
if nodes_diff.get("removed"):
|
|
66
|
+
tags.append("node-removed")
|
|
67
|
+
for change in nodes_diff.get("changed", []):
|
|
68
|
+
old_node, new_node = change["old"], change["new"]
|
|
69
|
+
if old_node.pin_function != new_node.pin_function:
|
|
70
|
+
tags.append("pin-function-changed")
|
|
71
|
+
if old_node.pintype != new_node.pintype:
|
|
72
|
+
tags.append("pin-type-changed")
|
|
73
|
+
|
|
74
|
+
return sorted(set(tags))
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: kigit
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: KiCad-semantics aware git diff and git status CLI tool
|
|
5
|
+
Requires-Dist: typer>=0.25.1
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# kigit
|
|
10
|
+
|
|
11
|
+
**KiCad-aware `git diff` and `git status` for your schematics.**
|
|
12
|
+
|
|
13
|
+
[](https://pypi.org/project/kigit/)
|
|
14
|
+
[](https://pypi.org/project/kigit/)
|
|
15
|
+
[](https://opensource.org/licenses/MIT)
|
|
16
|
+
|
|
17
|
+
`kigit` is a command-line tool that makes Git output for [KiCad](https://www.kicad.org/)
|
|
18
|
+
projects readable. Raw `git diff` on a `.kicad_sch` file is dominated by formatting,
|
|
19
|
+
re-ordering, and tool-generated metadata, so it tells you *that* a file changed but not
|
|
20
|
+
*what* meaningfully changed. `kigit` cuts through that noise and reports the change in
|
|
21
|
+
electrical terms instead: which nets were added, removed, renamed, or rewired.
|
|
22
|
+
|
|
23
|
+
> **Status: early preview (0.0.1).** This is the first public release. It focuses on the
|
|
24
|
+
> schematic netlist and intentionally does a small thing well. Interfaces and output may
|
|
25
|
+
> change in future versions. Bug reports and feedback are very welcome.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## What it does
|
|
30
|
+
|
|
31
|
+
`kigit` answers questions a plain text diff struggles with, such as *"Did the wiring
|
|
32
|
+
actually change, or is this just KiCad reshuffling the file?"*
|
|
33
|
+
|
|
34
|
+
For the schematic in your repository it:
|
|
35
|
+
|
|
36
|
+
- locates the schematic via your project (`.kicad_pro`) file,
|
|
37
|
+
- exports the **netlist** for two project states using KiCad's own `kicad-cli`,
|
|
38
|
+
- compares the two netlists semantically, ignoring text-level noise, and
|
|
39
|
+
- prints a structured, color-coded summary of the real changes.
|
|
40
|
+
|
|
41
|
+
It reports nets that were **added**, **removed**, **changed** (nodes/pins added,
|
|
42
|
+
removed, or modified; net-class changes), and **renamed** (a net whose connections are
|
|
43
|
+
unchanged but whose name differs). Each changed net is annotated with semantic tags such
|
|
44
|
+
as `power-net`, `ground-net`, `critical-net`, `high-speed-net`, connectivity level, and
|
|
45
|
+
impact tags like `connectivity-increased`, `node-added`, or `pin-function-changed`.
|
|
46
|
+
Purely cosmetic differences and unconnected (`unconnected-*`) nets are filtered out.
|
|
47
|
+
|
|
48
|
+
### Not in scope yet
|
|
49
|
+
|
|
50
|
+
To set expectations for 0.0.1, the following are **not** analyzed yet and are planned for
|
|
51
|
+
later releases:
|
|
52
|
+
|
|
53
|
+
- component **value** changes (e.g. a resistor going from 10k to 4.7k),
|
|
54
|
+
- **PCB layout** (`.kicad_pcb`) changes,
|
|
55
|
+
- further changes
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
`kigit` orchestrates the tools you already use; it does not reimplement them.
|
|
62
|
+
|
|
63
|
+
- **Python** 3.10 or newer.
|
|
64
|
+
- **Git**, available on your `PATH`.
|
|
65
|
+
- **KiCad 10.x**, with the bundled **`kicad-cli`** available on your `PATH`.
|
|
66
|
+
(`kicad-cli` ships with the standard KiCad installation on Windows, Linux, and macOS.)
|
|
67
|
+
|
|
68
|
+
`kigit` is cross-platform and runs on **Windows and Linux** (and other platforms where
|
|
69
|
+
Python, Git, and KiCad are available). Run `kigit check-health` at any time to confirm
|
|
70
|
+
your environment is ready.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Installation
|
|
75
|
+
|
|
76
|
+
From PyPI with `pip`:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install kigit
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or as an isolated tool with [uv](https://docs.astral.sh/uv/):
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uv tool install kigit
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Quick start
|
|
91
|
+
|
|
92
|
+
> **Prerequisite:** your schematic must be committed to Git at least once. `kigit`
|
|
93
|
+
> retrieves historical versions with `git show`, so an untracked schematic cannot be
|
|
94
|
+
> diffed.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# Compare your working changes against the last commit (the "status" use case):
|
|
98
|
+
kigit diff
|
|
99
|
+
|
|
100
|
+
# Compare two commits, branches, or tags:
|
|
101
|
+
kigit diff -c1 main -c2 my-feature-branch
|
|
102
|
+
|
|
103
|
+
# See full detail instead of a summary:
|
|
104
|
+
kigit diff --verbose
|
|
105
|
+
|
|
106
|
+
# Confirm Git and KiCad are installed and reachable:
|
|
107
|
+
kigit check-health
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Commands
|
|
113
|
+
|
|
114
|
+
Run `kigit` or `kigit --help` for general help, and `kigit COMMAND --help` for details on
|
|
115
|
+
a specific command.
|
|
116
|
+
|
|
117
|
+
### `kigit diff`
|
|
118
|
+
|
|
119
|
+
Summarizes the differences between two KiCad project states, filtering out noise from
|
|
120
|
+
formatting, ordering, and tool-generated metadata.
|
|
121
|
+
|
|
122
|
+
| Option | Alias | Default | Description |
|
|
123
|
+
| --- | --- | --- | --- |
|
|
124
|
+
| `--c1-ref` | `-c1` | `HEAD` | Git reference (commit, branch, or tag) used as the **first** state for comparison. |
|
|
125
|
+
| `--c2-ref` | `-c2` | *none* | Git reference used as the **second** state. When omitted, the first ref is compared against the **working-directory** schematic. |
|
|
126
|
+
| `--search-depth` | `-p` | `2` | How many directory levels to descend when locating the schematic file. |
|
|
127
|
+
| `--search-directory` | `-d` | `.` | Root directory for the schematic file lookup. |
|
|
128
|
+
| `--verbose` | `-v` | `false` | Show a detailed, per-net and per-node breakdown instead of a brief count summary. |
|
|
129
|
+
|
|
130
|
+
Behavior of the default invocation: with no `-c2`, `kigit diff` compares the first
|
|
131
|
+
reference (default `HEAD`) against the current schematic on disk — i.e. "what have I
|
|
132
|
+
changed since my last commit?".
|
|
133
|
+
|
|
134
|
+
### `kigit check-health`
|
|
135
|
+
|
|
136
|
+
Verifies that all required dependencies (Git and `kicad-cli`) are present on the system
|
|
137
|
+
and reports any that are missing.
|
|
138
|
+
|
|
139
|
+
### `kigit version`
|
|
140
|
+
|
|
141
|
+
Prints the installed `kigit` version.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Example output
|
|
146
|
+
|
|
147
|
+
> The examples below are illustrative; actual output is color-coded in your terminal.
|
|
148
|
+
|
|
149
|
+
Brief summary (default):
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
+ 3 net(s) added
|
|
153
|
+
- 1 net(s) removed
|
|
154
|
+
~ 2 net(s) changed
|
|
155
|
+
~ 1 net(s) renamed
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Detailed view (`--verbose`), showing how a changed net is annotated:
|
|
159
|
+
|
|
160
|
+
```text
|
|
161
|
+
Changed Nets:
|
|
162
|
+
Net '+5V':
|
|
163
|
+
* properties: critical-net, power-net, medium-connectivity
|
|
164
|
+
* impact: connectivity-increased, critical-net-change, node-added
|
|
165
|
+
Added nodes:
|
|
166
|
+
+ U3 pin 4 (VCC, power_in)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
When nothing meaningful changed:
|
|
170
|
+
|
|
171
|
+
```text
|
|
172
|
+
No semantically meaningful changes to show.
|
|
173
|
+
|
|
174
|
+
(Hint: only added, removed, changed and renamed items are shown (i.e. code changes are discarded.)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## How it works
|
|
180
|
+
|
|
181
|
+
1. **Locate** — find the project's `.kicad_pro` and the matching `.kicad_sch` within the
|
|
182
|
+
configured search directory and depth.
|
|
183
|
+
2. **Retrieve** — for each requested Git reference, extract that version of the schematic
|
|
184
|
+
with `git show <ref>:<path>` into a temporary working area.
|
|
185
|
+
3. **Export** — run `kicad-cli sch export netlist --format kicadxml` on each version to
|
|
186
|
+
produce a netlist in KiCad XML.
|
|
187
|
+
4. **Parse** — read the `<nets>` section into an immutable model of nets and their
|
|
188
|
+
nodes (component reference, pin, pin function, pin type).
|
|
189
|
+
5. **Diff** — compute added / removed / changed / renamed nets, ignoring net codes and
|
|
190
|
+
`unconnected-*` nets, and detect renames by matching nets with identical connections.
|
|
191
|
+
6. **Classify & report** — tag each net and change with semantic categories and print a
|
|
192
|
+
readable summary.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Logs and temporary files
|
|
197
|
+
|
|
198
|
+
- Diagnostic logs are written to a `kigit-log/` directory in the current working
|
|
199
|
+
directory. If a run fails unexpectedly, this is the first place to look.
|
|
200
|
+
- Temporary working data (`.kigit_temp/`, `.kigit_netlist_temp/`) is created during a run
|
|
201
|
+
and cleaned up automatically afterwards.
|
|
202
|
+
|
|
203
|
+
You may wish to add `kigit-log/`, `.kigit_temp/`, and `.kigit_netlist_temp/` to your
|
|
204
|
+
`.gitignore`.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Development
|
|
209
|
+
|
|
210
|
+
The project uses [uv](https://docs.astral.sh/uv/) for packaging, [pytest](https://docs.pytest.org/)
|
|
211
|
+
and [Hypothesis](https://hypothesis.readthedocs.io/) for testing, and
|
|
212
|
+
[Typer](https://typer.tiangolo.com/) for the command-line interface.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
`kigit` is released under the [MIT License](LICENSE).
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Acknowledgements
|
|
223
|
+
|
|
224
|
+
`kigit` is developed as a Software Engineering course project for and with help from
|
|
225
|
+
Auto-Intern GmbH, whose hardware team uses KiCad and Git for industrial monitoring
|
|
226
|
+
systems. The goal is to help engineers make better commit decisions and to keep project
|
|
227
|
+
history readable, with the intention of maintaining it as an open-source tool for the
|
|
228
|
+
wider KiCad community.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
kigit/__init__.py,sha256=Er02rv_MVlDoGZxPg9k32_6Lmlz3nY09F12A8NvW4X0,168
|
|
2
|
+
kigit/cli.py,sha256=M_s2TVGnVuvR9DNuEzPtgcSvPVgUxQlSfEE5dQi0uPU,4784
|
|
3
|
+
kigit/constants.py,sha256=WDLKbiPJ4veusBMLa30XKE5huRUpmw3YYn2OCMPw8Wg,1216
|
|
4
|
+
kigit/diff.py,sha256=gaQHWULXJ3-ImNyECztcVEZpyUSxK7JwhX1QP6Imzb4,3470
|
|
5
|
+
kigit/extractor.py,sha256=yz-xEV3eeGEAvA-KHJ9tBbQrDKU7Q-GUUYViqCQF37A,1278
|
|
6
|
+
kigit/file_ops.py,sha256=d2yyDHRRYqfYVsVoeludo90vD5-JTCtWD8Ga3XipCwU,2581
|
|
7
|
+
kigit/git_operations.py,sha256=CeZAisab3l1fn3YXIwy5fe6HPYBilPx0nvQ6Wmbz0vU,940
|
|
8
|
+
kigit/health_checker.py,sha256=xMc1cMJ397DDD6c7wvulUH5n5y5i7yslCxCfY71prkA,1431
|
|
9
|
+
kigit/logger.py,sha256=osAgd6q8RjU6wB6qYws1S7aSmztbkOXZjEvUo7JU9YI,893
|
|
10
|
+
kigit/message_printer.py,sha256=c5inyH9lUsLwfqGvhE4b2kN4dQZ3L_971rWPGhiXS2M,893
|
|
11
|
+
kigit/output_generator.py,sha256=V1x-XMzIt_Q_rUrv1zYibA-A3csEX5G82eJZaBwy2Yo,7805
|
|
12
|
+
kigit/parser.py,sha256=hgcnnO1XuT_BV2LhLIxNgGHxNPGuBPjgfDJDb0QNyXk,1472
|
|
13
|
+
kigit/semantic_categorization.py,sha256=TGit2Q8sXcEO7LRnrS8qbLeYiuiZ4sLK12h632-i4Ak,2487
|
|
14
|
+
kigit-0.0.1.dist-info/WHEEL,sha256=1uB4K0BXH0KDzZOLGYqB4NnSgGon9oosxpA-QGkDuEA,80
|
|
15
|
+
kigit-0.0.1.dist-info/entry_points.txt,sha256=92yizwoTA61iqADxwmHAfqdTWVEXavZkjDyHcr_KlCY,41
|
|
16
|
+
kigit-0.0.1.dist-info/METADATA,sha256=5cXL3PnTQ8qgRTzjrt-JrS9bqGlLGBBOdxyQeGWI9mo,7843
|
|
17
|
+
kigit-0.0.1.dist-info/RECORD,,
|