fix: config save broken (relative_to→relpath), startup validation, auto-create, untrack config.yaml

- _relative_path(): replace Path.relative_to() with os.path.relpath()
  (ValueError crossing filesystems silently returned absolute paths)
- _sync_config_from_ui(): always derive relative from get_path() absolute,
  never trust stale FileSelector._relative_path after Browse
- validate_paths(): check all file paths exist on startup, clear missing
- Clear UI selectors after validation to prevent re-save of stale paths
- _load_config(): auto-create blank config.yaml when missing on disk
- .gitignore: add config.yaml (user-specific, must not be tracked)
This commit is contained in:
2026-06-12 15:02:09 +08:00
parent 9238cbc63e
commit 9502bba636
4 changed files with 84 additions and 47 deletions
+2 -1
View File
@@ -33,7 +33,8 @@ htmlcov/
# ── Opencode (internal tooling) ────────────────────────────── # ── Opencode (internal tooling) ──────────────────────────────
.opencode/ .opencode/
# ── Config overrides (user-specific) ───────────────────────── # ── Config (user-specific, auto-generated if missing) ─────────
config.yaml
config/zynq_flasher.yaml config/zynq_flasher.yaml
# ── Logs ───────────────────────────────────────────────────── # ── Logs ─────────────────────────────────────────────────────
-27
View File
@@ -1,27 +0,0 @@
boot_wait_delay: 10
bootloader_bin_path: /home/ly0kos/work/Verify/FW/tftp_app/bootloader/bootloader_z100_800M.bin
bootloader_bit_path: /home/ly0kos/work/Verify/FW/tftp_app/bootloader/pl_arm_wrapper_hw_platform_0/pl_arm_wrapper.bit
bootloader_elf_path: /home/ly0kos/work/Verify/FW/tftp_app/tftp_app/tftp.elf
erase_all: false
firmware_bin_path: /home/ly0kos/work/Verify/FW/V1.3.1.3.3_06_01_pmu_cpld_check/V1.3.1.3.3_06_01_pmu_cpld_check.bin
flash_model: s25fl256s1
flash_type: qspi-x4-single
fsbl_elf_path: /home/ly0kos/work/Verify/FPGA/Xilinx/Z100/fsbl_qspi_bypass.elf
inter_step_delay: 2
ping_count: 3
ping_timeout: 5
reboot_timeout: 30
serial_baudrate: 115200
serial_port: /dev/ttyUSB0
step_timeouts:
bitstream: 60
check_env: 30
elf_download: 60
flash_erase: 120
flash_program: 600
tftp_reboot: 120
tftp_upload_name: z7bin
uart_delay: 3
xilinx_path: /data/xilinx
zynq_ip: 192.168.100.11
zynq_part: XC7Z100
+34 -2
View File
@@ -190,8 +190,11 @@ class Config:
def _relative_path(self, path: str) -> str: def _relative_path(self, path: str) -> str:
"""Convert an absolute path to a relative path from the config file. """Convert an absolute path to a relative path from the config file.
Uses :func:`os.path.relpath` which works across filesystem
boundaries (unlike :meth:`Path.relative_to`).
Args: Args:
path: Absolute path string. path: Absolute or relative path string.
Returns: Returns:
Relative path string, or the original if conversion fails. Relative path string, or the original if conversion fails.
@@ -201,7 +204,8 @@ class Config:
try: try:
target = Path(path) target = Path(path)
if target.is_absolute(): if target.is_absolute():
return str(target.relative_to(self._config_path.parent)) base = str(self._config_path.parent)
return os.path.relpath(str(target), base)
except (ValueError, OSError): except (ValueError, OSError):
pass pass
return path return path
@@ -219,6 +223,34 @@ class Config:
return Path() return Path()
return (self._config_path.parent / relative_path).resolve() return (self._config_path.parent / relative_path).resolve()
def validate_paths(self) -> list[str]:
"""Validate file paths in config and clear those that don't exist.
Checks each file path field, resolves it against the config
directory, and clears it if the target file does not exist
on the current filesystem.
Returns:
List of field names that were cleared.
"""
cleared: list[str] = []
path_fields = [
"bootloader_bit_path",
"bootloader_elf_path",
"bootloader_bin_path",
"fsbl_elf_path",
"firmware_bin_path",
]
for attr in path_fields:
val = getattr(self, attr, "")
if not val:
continue
resolved = self.resolve_path(val)
if not resolved.is_file():
setattr(self, attr, "")
cleared.append(attr)
return cleared
@property @property
def config_path(self) -> Path | None: def config_path(self) -> Path | None:
"""Return the path to the config file on disk.""" """Return the path to the config file on disk."""
+44 -13
View File
@@ -92,6 +92,32 @@ class MainWindow(ctk.CTk):
self._load_config() self._load_config()
self._build_ui() self._build_ui()
# Validate file paths — clear any that don't exist on this machine
if self._config and self._config._config_path:
try:
cleared = self._config.validate_paths()
if cleared:
joined = ", ".join(cleared)
self._log_message(f" ⚠ Cleared stale paths from config: {joined}")
# Also clear the UI selectors so stale paths don't
# get re-saved by subsequent _auto_save_config calls
_selector_map = {
"bootloader_bit_path": self._bit_selector,
"bootloader_elf_path": self._elf_selector,
"bootloader_bin_path": self._bootloader_bin_selector,
"fsbl_elf_path": self._fsbl_selector,
"firmware_bin_path": self._firmware_bin_selector,
}
for attr in cleared:
sel = _selector_map.get(attr)
if sel:
sel.set_path("")
self._config.save()
self._log_message(f" ✓ Config saved after clearing stale paths")
except Exception as e:
self._log_message(f" ✗ Config validation failed: {e}")
self._update_step_dependencies() self._update_step_dependencies()
# Handle window close # Handle window close
@@ -156,21 +182,28 @@ class MainWindow(ctk.CTk):
return config_path return config_path
def _load_config(self) -> None: def _load_config(self) -> None:
"""Load configuration from file or create default.""" """Load configuration from file or create a blank one on disk.
If config.yaml does not exist or cannot be read, a new one
is created with default values and written to disk immediately.
"""
user_config_path = self._get_user_config_path()
if self._config_path: if self._config_path:
try: try:
self._config = Config.from_file(self._config_path) self._config = Config.from_file(self._config_path)
if self._config_path.name == "default_config.yaml": if self._config_path.name == "default_config.yaml":
user_config_path = self._get_user_config_path()
self._config._config_path = user_config_path self._config._config_path = user_config_path
return
except Exception: except Exception:
pass # Fall through and create default
# Config file missing or unreadable — create a blank one
self._config = Config.from_default() self._config = Config.from_default()
user_config_path = self._get_user_config_path()
self._config._config_path = user_config_path
else:
self._config = Config.from_default()
user_config_path = self._get_user_config_path()
self._config._config_path = user_config_path self._config._config_path = user_config_path
try:
self._config.save()
except Exception:
pass # Will be saved later by _auto_save_config
def _reload_config(self) -> None: def _reload_config(self) -> None:
"""Re-read config.yaml and update UI selectors with latest paths. """Re-read config.yaml and update UI selectors with latest paths.
@@ -234,7 +267,8 @@ class MainWindow(ctk.CTk):
if hasattr(self, '_port_var'): if hasattr(self, '_port_var'):
self._config.serial_port = self._port_var.get() self._config.serial_port = self._port_var.get()
# Sync file paths from FileSelectors # Sync file paths from FileSelectors
files = self._get_selected_files() # Always derive relative from absolute — the _relative_path
# stored in FileSelector can be stale after Browse.
for attr, selector in [ for attr, selector in [
("bootloader_bit_path", self._bit_selector), ("bootloader_bit_path", self._bit_selector),
("bootloader_elf_path", self._elf_selector), ("bootloader_elf_path", self._elf_selector),
@@ -242,14 +276,11 @@ class MainWindow(ctk.CTk):
("fsbl_elf_path", self._fsbl_selector), ("fsbl_elf_path", self._fsbl_selector),
("firmware_bin_path", self._firmware_bin_selector), ("firmware_bin_path", self._firmware_bin_selector),
]: ]:
rel = selector.get_relative_path()
if rel:
setattr(self._config, attr, rel)
else:
abs_path = selector.get_path() abs_path = selector.get_path()
if abs_path: if abs_path:
self._config._config_path # ensure _config_path is set
setattr(self._config, attr, self._config._relative_path(abs_path)) setattr(self._config, attr, self._config._relative_path(abs_path))
else:
setattr(self._config, attr, "")
# Sync erase checkbox (may not exist yet) # Sync erase checkbox (may not exist yet)
if hasattr(self, '_erase_cb_var'): if hasattr(self, '_erase_cb_var'):
self._config.erase_all = self._erase_cb_var.get() self._config.erase_all = self._erase_cb_var.get()