Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ Diff two AUTOSAR code-generation output folders (MATLAB/Simulink Embedded Coder)

Regenerating a Simulink model rewrites timestamps, UUIDs, comment banners and auto-generated variable names even when the behaviour is identical. A plain `git diff` or Beyond Compare run drowns the reviewer in that noise. This tool classifies every hunk as *real* or *ignorable*, then renders a self-contained HTML report with an AUTOSAR-level summary on top of the text diff.

**Zero dependencies** — Python 3.8+ standard library only. No pip install required, no server, no internet access.
**Zero dependencies** — Python 3.8+ standard library only. No pip install required, no server, no internet access. (The optional [side-by-side viewer](#side-by-side-viewer) adds PySide6; the CLI, GUI and HTML report stay dependency-free.)

- [Install](#install)
- [Quick start](#quick-start)
- [Command line](#command-line)
- [GUI](#gui)
- [Side-by-side viewer](#side-by-side-viewer)
- [What counts as noise](#what-counts-as-noise)
- [Moved block detection](#moved-block-detection)
- [AUTOSAR semantic summary](#autosar-semantic-summary)
Expand Down Expand Up @@ -68,6 +69,7 @@ Exit `2` is never silent: the terminal prints `!!`, the report gets a red banner
| `--exit-zero` | Always exit 0 even when real changes exist (report-only mode for pipelines). Compare errors still exit 2 |
| `--arxml-only` | Scan only `.arxml`/`.xml`/`.a2l` and write a compact report (default `arxml_update.html`): a **per-type verdict** (`ARXML updated: …` / `A2L updated: …`, or `no changes` / `no files found`), the updated files split per type, and the AUTOSAR/A2L changes. The report is **always written** — when nothing changed it says "No ARXML or A2L updates" rather than skipping the file, so a missing file is never confused with a crashed run |
| `--gui` | Open the GUI window instead of running in the terminal. `old_dir`/`new_dir` become optional and prefill the folder fields when given |
| `--qt` | Open the **side-by-side viewer** (PySide6): a folder tree plus a two-pane old/new diff with a change minimap, Beyond-Compare style. `old_dir`/`new_dir` are optional; when omitted the viewer prompts for them. Needs the `viewer` extra (see below) |

## GUI

Expand All @@ -79,6 +81,30 @@ A tkinter front panel (stdlib, no server) covering every CLI mode: browse for th

The scan runs on a worker thread — the window stays responsive and shows a progress bar. On completion you get a colour-coded verdict (green = no real change, orange = real changes, red = COMPARE INCOMPLETE), the same log the terminal prints, and an **Open report** button. It shares the `run_compare()` core with the CLI, so fail-safe semantics are identical: a worker that dies mid-run shows a red `RUN FAILED` instead of a half-finished result.

## Side-by-side viewer

```bash
pip install "codegen-compare-tool[viewer]" # or: pip install PySide6
python -m compare_tool --qt <old_gen_folder> <new_gen_folder>
```

A Beyond-Compare-style desktop app (PySide6) for reviewing changes interactively instead of scrolling an HTML report:

- **Folder tree** on the left, each file coloured by verdict (Modified / Unimportant / Added / Deleted / Identical / **NOT compared**). A path filter and *Show: Identical / Unimportant* toggles narrow it down; by default both are off, so the tree opens on real changes only.
- **Two-pane diff** on the right: old and new aligned line-for-line and scrolled in lockstep, real changes in red/green, generator noise in yellow, moved blocks in blue, with the exact changed characters highlighted inside each line — the same classification the report uses.
- **Change minimap** down the right edge: the whole file compressed to one bar per change, with a viewport box; click or drag to jump.
- **`F7` / `F8`** step to the previous / next real change (noise is skipped). For `.arxml`/`.a2l` files the header shows the AUTOSAR / A2L rollup (`+1 port · ~1 event`, …).

PySide6 is imported only under `--qt`, so the CLI and the HTML report keep working on a headless box with no Qt installed. Fail-safe is unchanged: an uncompared path raises a red **COMPARE INCOMPLETE** banner and a scan crash shows a loud failure — never an empty, clean-looking tree.

**Standalone `.exe`** — to hand the viewer to colleagues who have no Python, build a single self-contained binary with [PyInstaller](https://pyinstaller.org):

```powershell
powershell -ExecutionPolicy Bypass -File packaging\build-viewer.ps1
```

That produces `dist\CodeGenCompareViewer.exe` (~45 MB) from [`packaging/compare-viewer.spec`](packaging/compare-viewer.spec) — double-click to open, or pass two folder paths as arguments to prefill OLD/NEW. PyInstaller does not cross-compile, so build on the OS you are targeting.

## What counts as noise

| Kind | Rule | Files |
Expand Down
9 changes: 9 additions & 0 deletions compare_tool/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ def main(argv=None):
help='open the graphical front panel (tkinter) instead of '
'running in the terminal; old_dir/new_dir are '
'optional and prefill the folder fields when given')
ap.add_argument('--qt', action='store_true',
help='open the side-by-side compare viewer (PySide6): a '
'folder tree with a two-pane old/new diff, like Beyond '
'Compare. old_dir/new_dir are optional; when omitted '
'the viewer prompts for them')
ap.add_argument('--report', metavar='OUT.html', default=None,
help='HTML report output path (default: compare_report.html, '
'or arxml_update.html with --arxml-only)')
Expand All @@ -159,6 +164,10 @@ def main(argv=None):
if args.gui:
from .gui import run_gui # deferred: tkinter may be absent headless
return run_gui(args.old_dir, args.new_dir)
if args.qt:
from .qtviewer import run_viewer # deferred: PySide6 may be absent
return run_viewer(args.old_dir, args.new_dir, exclude=args.exclude,
arxml_only=args.arxml_only)
if not args.old_dir or not args.new_dir:
ap.error('old_dir and new_dir are required (or use --gui)')
# Windows consoles often run a legacy codepage (cp1252/cp437) that cannot
Expand Down
15 changes: 15 additions & 0 deletions compare_tool/qtviewer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""PySide6 side-by-side compare viewer (Beyond-Compare style).

Optional feature: PySide6 is imported lazily so the CLI and the HTML report
keep working on a headless box with no Qt installed. Launch:

python -m compare_tool --qt [old_dir] [new_dir]

``run_viewer`` lives in :mod:`compare_tool.qtviewer.app`; importing it pulls in
PySide6, so it is imported there, not here.
"""


def run_viewer(*args, **kwargs):
from .app import run_viewer as _run
return _run(*args, **kwargs)
260 changes: 260 additions & 0 deletions compare_tool/qtviewer/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
"""Viewer main window: folder tree on the left, diff pane on the right.

The scan runs on a :class:`ScanWorker` thread; the window only reacts to its
signals. Fail-safe stays first-class: a worker crash or any uncompared path
raises a loud red banner -- an incomplete compare must never look clean.
"""

import sys

from PySide6.QtCore import Qt
from PySide6.QtGui import QAction, QBrush, QColor, QPalette
from PySide6.QtWidgets import (QApplication, QCheckBox, QFileDialog, QHBoxLayout,
QLabel, QLineEdit, QMainWindow, QProgressBar,
QSplitter, QTreeWidget, QTreeWidgetItem,
QVBoxLayout, QWidget)

from ..diff_engine import RULES
from ..scanner import summarize
from .diffpane import DiffPane
from .tree import STATUS, build_nodes, filter_nodes
from .worker import ScanWorker

REL_ROLE = Qt.UserRole # QTreeWidgetItem data slot holding a file's rel path


def _arxml_include():
"""Same include globs run_compare uses for --arxml-only."""
return tuple('*' + ext for ext, rs in RULES.items() if rs in ('arxml', 'a2l'))


class MainWindow(QMainWindow):
def __init__(self, old=None, new=None, exclude=(), arxml_only=False):
super().__init__()
self.old = old
self.new = new
self.exclude = tuple(exclude)
self.include = _arxml_include() if arxml_only else ()
self.results = {}
self.worker = None

self.setWindowTitle('AUTOSAR CodeGen Compare — viewer')
self.resize(1200, 800)

self.banner = QLabel()
self.banner.setVisible(False)
self.banner.setWordWrap(True)
self.banner.setStyleSheet('background:#4a1d1d; color:#ffd6d6; padding:6px 10px;'
'font-weight:bold; border-bottom:1px solid #b04a4a;')

self.tree = QTreeWidget()
self.tree.setHeaderLabels(['File', 'Status'])
self.tree.setColumnWidth(0, 380)
self.tree.setUniformRowHeights(True)
self.tree.itemSelectionChanged.connect(self._on_select)

# filter row: path search + status toggles. Defaults hide noise
# (identical + unimportant) so the tree opens on real changes.
self.filter_edit = QLineEdit()
self.filter_edit.setPlaceholderText('Filter by path…')
self.filter_edit.setClearButtonEnabled(True)
self.filter_edit.textChanged.connect(self._refresh_tree)
self.cb_identical = QCheckBox('Identical')
self.cb_unimportant = QCheckBox('Unimportant')
self.cb_identical.toggled.connect(self._refresh_tree)
self.cb_unimportant.toggled.connect(self._refresh_tree)
toggles = QHBoxLayout()
toggles.setContentsMargins(0, 0, 0, 0)
toggles.addWidget(QLabel('Show:'))
toggles.addWidget(self.cb_identical)
toggles.addWidget(self.cb_unimportant)
toggles.addStretch(1)
left = QWidget()
lv = QVBoxLayout(left)
lv.setContentsMargins(6, 6, 6, 0)
lv.setSpacing(4)
lv.addWidget(self.filter_edit)
lv.addLayout(toggles)
lv.addWidget(self.tree, 1)

self.diff = DiffPane()

split = QSplitter(Qt.Horizontal)
split.addWidget(left)
split.addWidget(self.diff)
split.setStretchFactor(0, 0)
split.setStretchFactor(1, 1)
split.setSizes([400, 800])

central = QWidget()
v = QVBoxLayout(central)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
v.addWidget(self.banner)
v.addWidget(split)
self.setCentralWidget(central)

self.progress = QProgressBar()
self.progress.setMaximumWidth(240)
self.progress.setVisible(False)
self.statusBar().addPermanentWidget(self.progress)

tb = self.addToolBar('main')
tb.setMovable(False)
act_open = QAction('Open folders…', self)
act_open.triggered.connect(self._pick_folders)
act_rescan = QAction('Rescan', self)
act_rescan.triggered.connect(self._start_scan)
tb.addAction(act_open)
tb.addAction(act_rescan)
tb.addSeparator()
act_prev = QAction('◀ Prev change', self)
act_prev.setShortcut('F7')
act_prev.triggered.connect(lambda: self.diff.prev_change())
act_next = QAction('Next change ▶', self)
act_next.setShortcut('F8')
act_next.triggered.connect(lambda: self.diff.next_change())
tb.addAction(act_prev)
tb.addAction(act_next)

if self.old and self.new:
self._start_scan()
else:
self._pick_folders()

# --- folder selection ---

def _pick_folders(self):
o = QFileDialog.getExistingDirectory(self, 'Select OLD folder', self.old or '')
if not o:
return
n = QFileDialog.getExistingDirectory(self, 'Select NEW folder', self.new or o)
if not n:
return
self.old, self.new = o, n
self._start_scan()

# --- scan lifecycle ---

def _start_scan(self):
if not (self.old and self.new):
return
if self.worker and self.worker.isRunning():
return
self.banner.setVisible(False)
self.tree.clear()
self.diff.clear()
self.results = {}
self.progress.setVisible(True)
self.progress.setRange(0, 0) # busy/indeterminate until first tick
self.setWindowTitle('AUTOSAR CodeGen Compare — {} → {}'.format(self.old, self.new))
self.statusBar().showMessage('Scanning…')
self.worker = ScanWorker(self.old, self.new, self.exclude, self.include)
self.worker.progressed.connect(self._on_progress)
self.worker.done.connect(self._on_done)
self.worker.failed.connect(self._on_fail)
self.worker.start()

def _on_progress(self, done, total, rel):
self.progress.setRange(0, max(total, 1))
self.progress.setValue(done)
self.statusBar().showMessage('Scanning {}/{}: {}'.format(done, total, rel))

def _on_done(self, results):
self.results = results
self._refresh_tree()
counts = summarize(results)
self.progress.setRange(0, 1)
self.progress.setValue(1)
self.progress.setVisible(False)
if counts['error']:
errs = sorted(rel for rel, r in results.items() if r['status'] == 'error')
shown = ', '.join(errs[:20]) + (' …' if len(errs) > 20 else '')
self.banner.setText('⚠ COMPARE INCOMPLETE — {} path(s) NOT compared '
'(treat as potentially changed): {}'.format(len(errs), shown))
self.banner.setVisible(True)
self.statusBar().showMessage(
'{real-change} modified · {ignorable-only} unimportant · {added} added · '
'{deleted} deleted · {identical} identical · {error} error(s)'.format(**counts))

def _on_fail(self, msg):
self.progress.setVisible(False)
self.banner.setText('‼ SCAN FAILED — no results (treat everything as '
'potentially changed): {}'.format(msg))
self.banner.setVisible(True)
self.statusBar().showMessage('SCAN FAILED')

# --- tree fill + selection ---

def _refresh_tree(self):
"""Rebuild the tree from results under the current filter + toggles.
Cheap enough to run on every keystroke; selection is not preserved."""
self.tree.clear()
if not self.results:
return
nodes = filter_nodes(build_nodes(self.results),
show_identical=self.cb_identical.isChecked(),
show_unimportant=self.cb_unimportant.isChecked(),
text=self.filter_edit.text())
self._fill_tree(nodes)

def _fill_tree(self, nodes):
def add(parent, node):
marker, label, color = STATUS[node.status]
item = QTreeWidgetItem(['{} {}'.format(marker, node.name), label])
brush = QBrush(QColor(color))
item.setForeground(0, brush)
item.setForeground(1, brush)
if not node.is_dir:
item.setData(0, REL_ROLE, node.rel)
for ch in node.children:
add(item, ch)
if parent is None:
self.tree.addTopLevelItem(item)
else:
parent.addChild(item)
if node.is_dir:
item.setExpanded(True)

for n in nodes:
add(None, n)

def _on_select(self):
items = self.tree.selectedItems()
if not items:
return
rel = items[0].data(0, REL_ROLE)
if rel and rel in self.results:
self.diff.show_file(rel, self.results[rel], self.old, self.new)


def _apply_dark(app):
"""Fusion dark palette so the viewer matches the report's dark identity."""
app.setStyle('Fusion')
p = QPalette()
bg, base, text = QColor('#1e1f22'), QColor('#232427'), QColor('#d4d4d4')
p.setColor(QPalette.Window, bg)
p.setColor(QPalette.Base, base)
p.setColor(QPalette.AlternateBase, bg)
p.setColor(QPalette.Text, text)
p.setColor(QPalette.WindowText, text)
p.setColor(QPalette.Button, base)
p.setColor(QPalette.ButtonText, text)
p.setColor(QPalette.Highlight, QColor('#3a5a7a'))
p.setColor(QPalette.HighlightedText, QColor('#ffffff'))
app.setPalette(p)


def run_viewer(old=None, new=None, exclude=(), arxml_only=False):
app = QApplication.instance()
owns = app is None
if owns:
app = QApplication(sys.argv[:1])
_apply_dark(app)
win = MainWindow(old, new, exclude, arxml_only)
win.show()
return app.exec() if owns else 0


if __name__ == '__main__':
sys.exit(run_viewer())
Loading
Loading