Prepare Windows portable packaging

This commit is contained in:
2026-09-03 12:32:35 -04:00
parent 6d0a23a72e
commit bbb369b65b
6 changed files with 110 additions and 4 deletions
+1
View File
@@ -7,6 +7,7 @@ __pycache__/
.ruff_cache/ .ruff_cache/
dist/ dist/
build/ build/
dist/
# Sensitive vault exports, logs, and local runtime state # Sensitive vault exports, logs, and local runtime state
*.json *.json
+33
View File
@@ -0,0 +1,33 @@
# PyInstaller one-file Windows build configuration.
# Build from the repository root with: pyinstaller --clean --noconfirm DataLoom.spec
from PyInstaller.utils.hooks import collect_submodules
hiddenimports = collect_submodules("PySide6")
a = Analysis(
["src/dataloom/app.py"],
pathex=["src"],
binaries=[],
datas=[],
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="DataLoom",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
)
+13
View File
@@ -0,0 +1,13 @@
$ErrorActionPreference = "Stop"
Write-Host "Creating or reusing the DataLoom Windows build environment..."
if (-not (Test-Path ".venv\\Scripts\\python.exe")) {
py -3 -m venv .venv
}
& .venv\\Scripts\\python.exe -m pip install --upgrade pip
& .venv\\Scripts\\python.exe -m pip install -e ".[build,test]"
& .venv\\Scripts\\python.exe -m pytest -q
& .venv\\Scripts\\python.exe -m PyInstaller --clean --noconfirm DataLoom.spec
Write-Host "Portable executable created at dist\\DataLoom.exe"
+41
View File
@@ -0,0 +1,41 @@
# Windows 11 portable build
The final portable executable must be built on Windows 11 because PyInstaller produces a platform-specific executable.
## Requirements
- Windows 11 64-bit
- Python 3.11 or newer from python.org, with the Python launcher (`py`) available
- Git, or a ZIP copy of this repository
- Internet access during the one-time dependency installation only
## Build
Open PowerShell in the repository root and run:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\\build-windows.ps1
```
The script creates a project-local virtual environment, installs the application and build/test dependencies, runs the synthetic tests, and creates:
```text
dist\\DataLoom.exe
```
The executable is intended to be portable and does not require Python on the destination Windows machine.
## Validation checklist
- Launch `dist\\DataLoom.exe` on Windows 11.
- Import `tests\\fixtures\\sample-export-a.json` and `sample-export-b.json`.
- Confirm 8 loaded, 7 included, 1 exact duplicate removed, and 2 review groups.
- Confirm secret logging is unchecked by default.
- Confirm the duplicate detail view shows both records and the newer revision preference.
- Export a merged JSON file and verify the displayed output and audit-log paths.
- Test on a second Windows 11 machine or clean user profile if practical.
## Sensitive data
Do not copy real Bitwarden exports, merged vaults, audit logs, `.venv`, `build`, or `dist` contents into the repository. Use copies of vault exports only for local testing.
+1
View File
@@ -12,6 +12,7 @@ dependencies = ["PySide6>=6.6"]
[project.optional-dependencies] [project.optional-dependencies]
test = ["pytest>=8.0"] test = ["pytest>=8.0"]
build = ["pyinstaller>=6.0"]
[project.scripts] [project.scripts]
dataloom = "dataloom.app:main" dataloom = "dataloom.app:main"
+21 -4
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QApplication, QFileDialog, QLabel, QListWidget, QAbstractItemView, QApplication, QFileDialog, QLabel, QListWidget,
QMainWindow, QMessageBox, QPushButton, QTabWidget, QTextEdit, QMainWindow, QMessageBox, QPushButton, QTabWidget, QTextEdit,
QVBoxLayout, QWidget, QCheckBox, QVBoxLayout, QWidget,
) )
from dataloom.core.audit import AuditLog from dataloom.core.audit import AuditLog
@@ -23,20 +23,26 @@ class MainWindow(QMainWindow):
self.audit = None self.audit = None
self.summary = QTextEdit(); self.summary.setReadOnly(True) self.summary = QTextEdit(); self.summary.setReadOnly(True)
self.duplicates = QListWidget(); self.duplicates.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.duplicates = QListWidget(); self.duplicates.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.duplicate_detail = QTextEdit(); self.duplicate_detail.setReadOnly(True)
self.duplicates.currentRowChanged.connect(self.show_duplicate_detail)
self.log_view = QTextEdit(); self.log_view.setReadOnly(True) self.log_view = QTextEdit(); self.log_view.setReadOnly(True)
self.entries_view = QListWidget() self.entries_view = QListWidget()
tabs = QTabWidget() tabs = QTabWidget()
tabs.addTab(self._page("Import Summary", self.summary), "Import Summary") tabs.addTab(self._page("Import Summary", self.summary), "Import Summary")
tabs.addTab(self._page("Merged Entries", self.entries_view), "Merged Entries") tabs.addTab(self._page("Merged Entries", self.entries_view), "Merged Entries")
tabs.addTab(self._page("Duplicate Review", self.duplicates), "Duplicate Review") duplicate_page = QWidget(); duplicate_layout = QVBoxLayout(duplicate_page)
duplicate_layout.addWidget(QLabel("<h2>Duplicate Review</h2>")); duplicate_layout.addWidget(self.duplicates); duplicate_layout.addWidget(self.duplicate_detail)
tabs.addTab(duplicate_page, "Duplicate Review")
tabs.addTab(self._page("Audit Log", self.log_view), "Audit Log") tabs.addTab(self._page("Audit Log", self.log_view), "Audit Log")
import_button = QPushButton("Import Bitwarden exports…") import_button = QPushButton("Import Bitwarden exports…")
import_button.clicked.connect(self.import_files) import_button.clicked.connect(self.import_files)
self.secret_logging = QCheckBox("Enable full secret logging (passwords, TOTP, notes, and vault contents)")
self.secret_logging.setChecked(False)
export_button = QPushButton("Export merged vault…") export_button = QPushButton("Export merged vault…")
export_button.setEnabled(False) export_button.setEnabled(False)
export_button.clicked.connect(self.export_merged) export_button.clicked.connect(self.export_merged)
self.export_button = export_button self.export_button = export_button
layout = QVBoxLayout(); layout.addWidget(QLabel("DataLoom processes exports locally. Do not import encrypted files.")); layout.addWidget(import_button); layout.addWidget(export_button); layout.addWidget(tabs) layout = QVBoxLayout(); layout.addWidget(QLabel("DataLoom processes exports locally. Do not import encrypted files.")); layout.addWidget(self.secret_logging); layout.addWidget(import_button); layout.addWidget(export_button); layout.addWidget(tabs)
root = QWidget(); root.setLayout(layout); self.setCentralWidget(root) root = QWidget(); root.setLayout(layout); self.setCentralWidget(root)
@staticmethod @staticmethod
@@ -55,7 +61,7 @@ class MainWindow(QMainWindow):
if len(set(formats)) != 1: raise ImportError("All selected files must use the same format") if len(set(formats)) != 1: raise ImportError("All selected files must use the same format")
self.records = records self.records = records
self.result = merge_records(records) self.result = merge_records(records)
self.audit = AuditLog(Path(paths[0]).parent / "dataloom-audit.log", include_secrets=True) self.audit = AuditLog(Path(paths[0]).parent / "dataloom-audit.log", include_secrets=self.secret_logging.isChecked())
self.audit.record("IMPORT", f"Loaded {len(paths)} {formats[0]} file(s) and {len(records)} record(s)") self.audit.record("IMPORT", f"Loaded {len(paths)} {formats[0]} file(s) and {len(records)} record(s)")
for record in records: for record in records:
self.audit.record("VIEW", f"{record.source_file} record {record.source_index} ({record.item_type}) {record.name}", repr(record.data)) self.audit.record("VIEW", f"{record.source_file} record {record.source_index} ({record.item_type}) {record.name}", repr(record.data))
@@ -95,6 +101,17 @@ class MainWindow(QMainWindow):
except OSError as exc: except OSError as exc:
QMessageBox.critical(self, "Export failed", str(exc)) QMessageBox.critical(self, "Export failed", str(exc))
def show_duplicate_detail(self, row: int) -> None:
if not self.result or row < 0 or row >= len(self.result.review_groups):
self.duplicate_detail.clear(); return
group = self.result.review_groups[row]
lines = [f"Classification: {group.classification}", f"Reason: {group.reason}"]
if group.selected:
lines.append(f"Automatic preference: {group.selected.source_file}:{group.selected.source_index} ({group.selected.name})")
for index, record in enumerate(group.records, 1):
lines.extend(["", f"Record {index}: {record.source_file}:{record.source_index} — {record.name}", repr(record.data)])
self.duplicate_detail.setPlainText("\n".join(lines))
def run() -> int: def run() -> int:
app = QApplication([]) app = QApplication([])