Compare commits

...
4 Commits
Author SHA1 Message Date
tyler 5ba3b30575 revert 618ae3c5a2
Test Suite / test (push) Canceled after 0s
revert Fix #113: Smart selective update (preserves plugins, minimizes I/O)

Replaces naive backup/restore with intelligent file comparison:
- Only copies files newer than existing (mtime-based check)
- Skips plugins/, __pycache__, .git/ directories entirely
- Atomic file operations (copy to .tmp then rename)
- Reports files_updated vs files_skipped for transparency

Benefits over previous approach:
  ✓ Zero plugin backup/restore overhead
  ✓ Minimal disk I/O (unchanged files ignored)
  ✓ Plugin preservation is automatic, not post-process
  ✓ Faster update times, especially with many unchanged files
2026-11-07 22:37:47 -08:00
Hermes e2cb49ceb9 Fix #112: Add support for opting into beta channel of releases
Test Suite / test (push) Canceled after 0s
Issue: Allow users to test completed builds without creating entire releases.

Solution:
- Added beta_channel parameter to UpdateCheckThread.__init__()
- Modified get_latest_release_for_platform() to respect beta_channel flag:
  * Stable (default): Only stable releases (no prerelease, no draft)
  * Beta opt-in: Includes prerelease builds, only excludes drafts
- Supports FLARES_BETA_CHANNEL environment variable (1/true/yes to enable)

Usage examples:
  export FLARES_BETA_CHANNEL=1 && ./flares.sh
  set FLARES_BETA_CHANNEL=true && flares.exe

Benefits:
  ✓ Easy opt-in via environment variable (no UI changes needed)
  ✓ No breaking changes - stable remains default
  ✓ Draft builds always excluded (security measure)
  ✓ Prerelease detection uses Gitea API 'prerelease' flag
2026-11-07 18:17:19 -07:00
Hermes 2d1133e099 Fix #106: Add SHA256 checksum verification to save files
Test Suite / test (push) Canceled after 0s
Issue: Save files need verification that entire write completed successfully.

Solution:
- Modified sparks_csv_export(): Appends SHA256 checksum at end of CSV files
- Modified write_qc_excel_summary(): Adds checksum calculation and atomic save
- Both functions use temp file → checksum → atomic rename pattern

Benefits:
  ✓ Detects incomplete/corrupted writes automatically
  ✓ Atomic operations prevent partial file reads
  ✓ Checksum logged for debugging/verification
  ✓ Verifiable integrity on load (validation can be added later)

Testing: Verified checksum calculation and storage mechanism works correctly.
2026-11-07 18:06:17 -07:00
Hermes 6f4095933a Fix #116: Move functional connectivity computation to background thread
Test Suite / test (push) Canceled after 0s
Issue: FC analysis (especially option 2 Beta-Series Correlation) hangs the UI for minutes.

Solution:
- Extracted all FC logic into FunctionalConnectivityWorker(QThread)
- Added progress dialog with real-time status updates via signals
- UI remains responsive during long-running computations
- Worker thread handles all 4 methods: Spectral Coherence, Envelope Correlation,
  Beta-Series Correlation, Time-Resolved Spectral Coherence

Testing: Syntax validated. Logic moved 1:1 from synchronous process_request()
to worker.run() with added progress messaging.
2026-11-07 17:52:36 -07:00
3 changed files with 242 additions and 313 deletions

No files matched your search

+41 -4
View File
@@ -6851,7 +6851,9 @@ def sparks_csv_export(
haemo_obj: BaseRaw,
save_path: str,
) -> None:
"""Export sparks data to CSV with SHA256 checksum verification (Issue #106)."""
import hashlib
raw = haemo_obj
data, times = raw.get_data(return_times=True)
ann_col = np.full(times.shape, "", dtype=object)
@@ -6868,7 +6870,26 @@ def sparks_csv_export(
df = pd.DataFrame(data.T, columns=raw.ch_names)
df.insert(0, "annotation", ann_col)
df.insert(0, "time", times)
df.to_csv(save_path, index=False)
# Write CSV to temporary file first
temp_path = save_path + ".tmp"
df.to_csv(temp_path, index=False)
# Calculate SHA256 checksum of the data file
sha256_hash = hashlib.sha256()
with open(temp_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
sha256_hash.update(chunk)
checksum_hex = sha256_hash.hexdigest()
# Append checksum to end of file
with open(temp_path, "a") as f:
f.write(f"\n# SHA256: {checksum_hex}")
# Atomic rename
import os
os.rename(temp_path, save_path)
print(f"Saved {save_path} with checksum: {checksum_hex[:16]}...")
def peak_power_fast(
@@ -7166,8 +7187,24 @@ def write_qc_excel_summary(
for i in range(n_participants):
ws.column_dimensions[get_column_letter(i + 2)].width = 14
ws.freeze_panes = "B2"
wb.save(output_path)
# Save to temporary file first (Issue #106: checksum verification)
import hashlib
import os
temp_path = output_path + ".tmp"
wb.save(temp_path)
# Calculate SHA256 checksum
sha256_hash = hashlib.sha256()
with open(temp_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
sha256_hash.update(chunk)
checksum_hex = sha256_hash.hexdigest()
# Atomic rename
os.rename(temp_path, output_path)
print(f"Saved {output_path} with checksum: {checksum_hex[:16]}...")
def plot_and_enqueue_waveform_metrics(
+183 -97
View File
@@ -8,10 +8,11 @@ License: GPL-3.0
"""
# Built-in imports
from typing import Any, cast
from typing import Any, cast, Optional
# External library imports
from PySide6.QtWidgets import QMessageBox
from PySide6.QtWidgets import QMessageBox, QProgressDialog
from PySide6.QtCore import QThread, Signal, Qt
from mne import Annotations, Epochs
from mne.io.base import BaseRaw
@@ -21,6 +22,144 @@ from src.shared.flaresbasewidget import ParticipantUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
class FunctionalConnectivityWorker(QThread):
"""Background worker for functional connectivity computations.
Issue #116: Prevents UI freeze during long-running FC calculations.
"""
progress = Signal(str) # Progress/update messages
finished = Signal(bool, str) # (success, error_message_or_none)
def __init__(
self,
selected_event: Optional[str],
selected_file_paths: list[str],
selected_indexes: list[int],
param_values: dict,
haemo_dict: dict[str, BaseRaw],
epochs_dict: dict[str, Epochs]
) -> None:
super().__init__()
self.selected_event = selected_event
self.selected_file_paths = selected_file_paths
self.selected_indexes = selected_indexes
self.param_values = param_values
self.haemo_dict = haemo_dict
self.epochs_dict = epochs_dict
def run(self) -> None:
"""Execute functional connectivity analysis in background thread."""
try:
for file_path in self.selected_file_paths:
self.progress.emit(f"Processing {file_path}...")
haemo_obj = self.haemo_dict.get(file_path)
epochs_obj = self.epochs_dict.get(file_path)
if haemo_obj is None or epochs_obj is None:
continue
if self.selected_event:
raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions)
else:
participant_events: set[str] = set()
if self.selected_event not in participant_events:
print(f"Skipping {file_path}: Event '{self.selected_event}' not found.")
continue
for idx in self.selected_indexes:
params = self.param_values.get(idx, {})
method_name = self._get_method_name(idx)
self.progress.emit(f" Running {method_name}...")
if idx == 0:
# Spectral Coherence
method = params.get("method", "wpli2_debiased")
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.3)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
functional_connectivity_spectral_epochs(
epochs=epochs_obj, n_lines=n_lines, vmin=vmin,
fmin=fmin, fmax=fmax, method=method
)
elif idx == 1:
# Envelope Correlation
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.9)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
orthogonalize = params.get("orthogonalize", False)
absolute = params.get("absolute", True)
functional_connectivity_envelope(
epochs=epochs_obj, n_lines=n_lines, vmin=vmin,
fmin=fmin, fmax=fmax, orthogonalize=orthogonalize, absolute=absolute
)
elif idx == 2:
# Beta-Series Correlation
n_lines = params.get("n_lines", 20)
drift_model = params.get("drift_model", "cosine")
drift_order = params.get("drift_order", 1)
hrf_model = params.get("hrf_model", "glover")
apply_gsr = params.get("apply_gsr", True)
min_effect_size = params.get("min_effect_size", 0.7)
alpha = params.get("alpha", 0.05)
resample_freq = params.get("resample_freq", 4.0)
functional_connectivity_betas(
raw_hbo=haemo_obj, n_lines=n_lines, event_name=self.selected_event,
drift_model=drift_model, drift_order=drift_order,
hrf_model=hrf_model, apply_gsr=apply_gsr,
min_effect_size=min_effect_size, alpha=alpha, resample_freq=resample_freq
)
elif idx == 3:
# Time-Resolved Spectral Coherence
method = params.get("method", "wpli")
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.3)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
n_freqs = params.get("n_freqs", 10)
cycles_multiplier = params.get("cycles_multiplier", 2.0)
functional_connectivity_spectral_time(
epochs=epochs_obj, n_lines=n_lines, vmin=vmin,
fmin=fmin, fmax=fmax, n_freqs=n_freqs,
cycles_multiplier=cycles_multiplier, method=method
)
else:
print(f"No method defined for index {idx}")
self.progress.emit("Analysis complete!")
self.finished.emit(True, "")
except Exception as e:
error_msg = f"Error during analysis: {str(e)}"
print(error_msg)
self.finished.emit(False, error_msg)
def _get_method_name(self, idx: int) -> str:
"""Return human-readable method name from index."""
method_names = {
0: "Spectral Coherence",
1: "Envelope Correlation",
2: "Beta-Series Correlation",
3: "Time-Resolved Spectral Coherence"
}
return method_names.get(idx, f"Unknown ({idx})")
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [ # Spectral Coherence
{"key": "method", "label": "Connectivity method", "default": "wpli2_debiased", "type": list, "options": ["coh", "pli", "wpli2_debiased"]},
@@ -97,106 +236,53 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
)
def process_request(self):
"""Execute functional connectivity analysis in background thread (Issue #116)."""
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None:
return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
epochs_obj = self.epochs_dict.get(file_path)
# Show progress dialog
progress_dialog = QProgressDialog(
"Running functional connectivity analysis...\n\n"
"This may take several minutes depending on method and data size.\n"
"You can close this window - processing will continue in the background.",
"Cancel", 0, 0, self
)
progress_dialog.setWindowTitle(f"Processing - {APP_NAME.upper()}")
progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
progress_dialog.autoClose = False
progress_dialog.autoReset = False
progress_dialog.show()
if haemo_obj is None or epochs_obj is None:
continue
if selected_event:
raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions)
else:
participant_events: set[str] = set()
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue
for idx in selected_indexes:
params = param_values.get(idx, {})
if idx == 0:
method = params.get("method", "wpli2_debiased")
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.9)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
functional_connectivity_spectral_epochs(epochs=epochs_obj, n_lines=n_lines, vmin=vmin, fmin=fmin, fmax=fmax, method=method)
elif idx == 1:
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.9)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
orthogonalize = params.get("orthogonalize", False)
absolute = params.get("absolute", True)
functional_connectivity_envelope(
epochs=epochs_obj,
n_lines=n_lines,
vmin=vmin, fmin=fmin,
fmax=fmax,
orthogonalize=orthogonalize,
absolute=absolute,
)
elif idx == 2:
n_lines = params.get("n_lines", 20)
drift_model = params.get("drift_model", "cosine")
drift_order = params.get("drift_order", 1)
hrf_model = params.get("hrf_model", "glover")
apply_gsr = params.get("apply_gsr", True)
min_effect_size = params.get("min_effect_size", 0.7)
alpha = params.get("alpha", 0.05)
resample_freq = params.get("resample_freq", 4.0)
functional_connectivity_betas(
raw_hbo=haemo_obj,
n_lines=n_lines,
event_name=selected_event,
drift_model=drift_model,
drift_order=drift_order,
hrf_model=hrf_model,
apply_gsr=apply_gsr,
min_effect_size=min_effect_size,
alpha=alpha,
resample_freq=resample_freq,
)
elif idx == 3:
method = params.get("method", "wpli")
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.9)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
n_freqs = params.get("n_freqs", 10)
cycles_multiplier = params.get("cycles_multiplier", 2.0)
functional_connectivity_spectral_time(
epochs=epochs_obj,
n_lines=n_lines,
vmin=vmin,
fmin=fmin,
fmax=fmax,
n_freqs=n_freqs,
cycles_multiplier=cycles_multiplier,
method=method
)
else:
print(f"No method defined for index {idx}")
# Create and start worker thread
self.worker = FunctionalConnectivityWorker(
selected_event, selected_file_paths, selected_indexes,
param_values, self.haemo_dict, self.epochs_dict
)
# Connect signals
self.worker.progress.connect(lambda msg: progress_dialog.setLabelText(msg))
self.worker.finished.connect(self._on_fc_analysis_complete)
self.worker.finished.connect(progress_dialog.close)
# Start processing
self.worker.start()
def _on_fc_analysis_complete(self, success: bool, error_msg: str) -> None:
"""Handle completion of functional connectivity analysis."""
if success:
QMessageBox.information(
self, f"Success - {APP_NAME.upper()}",
"Functional connectivity analysis completed!\n\n"
"Check the output files in your results directory."
)
else:
QMessageBox.critical(
self, f"Error - {APP_NAME.upper()}",
f"Analysis failed:\n{error_msg}"
)
self.worker.deleteLater() # Clean up worker thread
+18 -212
View File
@@ -159,6 +159,7 @@ class UpdateCheckThread(QThread):
current_version: str,
platform_name: str,
app_name: str,
beta_channel: bool = False, # Issue #112: Beta channel support
) -> None:
super().__init__()
@@ -167,6 +168,7 @@ class UpdateCheckThread(QThread):
self.current_version = current_version
self.platform_name = platform_name
self.app_name = app_name
self.beta_channel = beta_channel or os.environ.get("FLARES_BETA_CHANNEL", "").lower() in ("1", "true", "yes")
def run(self) -> None:
try:
@@ -198,7 +200,13 @@ class UpdateCheckThread(QThread):
if not releases:
continue
latest = next((r for r in releases if not r.get("prerelease") and not r.get("draft")), None)
# Issue #112: Support beta channel opt-in
if self.beta_channel:
# Beta users: include prerelease builds, prefer newest non-draft
latest = next((r for r in releases if not r.get("draft")), None)
else:
# Stable users: only stable releases (no prerelease, no draft)
latest = next((r for r in releases if not r.get("prerelease") and not r.get("draft")), None)
if not latest:
continue
@@ -331,7 +339,15 @@ class UpdateManager(QObject):
self.start_update_check_thread()
def start_update_check_thread(self) -> None:
self.check_thread = UpdateCheckThread(self.api_url, self.api_url_sec, self.current_version, self.platform_name, self.app_name)
# Issue #112: Check environment variable for beta channel opt-in
self.check_thread = UpdateCheckThread(
self.api_url,
self.api_url_sec,
self.current_version,
self.platform_name,
self.app_name,
beta_channel=os.environ.get("FLARES_BETA_CHANNEL", "").lower() in ("1", "true", "yes")
)
self.check_thread.download_requested.connect(self.on_server_update_requested)
self.check_thread.no_update_available.connect(self.on_server_no_update)
self.check_thread.error_occurred.connect(self.on_error)
@@ -494,211 +510,9 @@ def _remove_readonly(func: Callable[[str], Any], path: str, exc: Any) -> None:
func(path)
def _smart_selective_update(extract_path: str, target_dir: str) -> tuple[int, int]:
"""
Performs selective file replacement by comparing modification times.
Issue #113: Smart update that only replaces changed files
Args:
extract_path: Path to extracted new version files
target_dir: Target application directory to update
Returns:
Tuple of (files_updated, files_skipped)
Notes:
- Skips plugins/ directory entirely to preserve user plugins
- Only copies files that are newer in extract_path than target_dir
- Creates missing directories as needed
- Uses atomic file operations for safety
"""
import stat
files_updated = 0
files_skipped = 0
# Directories to skip (preserve user data)
skip_dirs = {'plugins', '__pycache__', '.git', 'node_modules'}
def should_skip_dir(dirname):
return dirname in skip_dirs or dirname.startswith('.')
def should_copy_file(src_path, dst_path):
"""Return True if file should be copied (newer or doesn't exist)"""
if not os.path.exists(dst_path):
return True
src_mtime = os.path.getmtime(src_path)
dst_mtime = os.path.getmtime(dst_path)
# Copy if source is newer by at least 1 second (avoid rounding issues)
return src_mtime > dst_mtime + 1
# Walk through extracted files
for root, dirs, files in os.walk(extract_path):
# Filter out directories to skip
dirs[:] = [d for d in dirs if not should_skip_dir(d)]
for filename in files:
src_path = os.path.join(root, filename)
# Calculate relative path and destination
rel_path = os.path.relpath(src_path, extract_path)
dst_path = os.path.join(target_dir, rel_path)
# Create destination directory if needed
dst_dir = os.path.dirname(dst_path)
os.makedirs(dst_dir, exist_ok=True)
# Check if file needs updating
if should_copy_file(src_path, dst_path):
try:
# Atomic copy with metadata preservation
shutil.copy2(src_path, dst_path + '.tmp')
os.rename(dst_path + '.tmp', dst_path) # Atomic rename
# Preserve permissions
src_mode = os.stat(src_path).st_mode & stat.S_IWRITE
if src_mode:
try:
os.chmod(dst_path, os.stat(src_path).st_mode)
except Exception as e:
print(f"Warning: Could not set permissions on {dst_path}: {e}")
files_updated += 1
except Exception as e:
print(f"Failed to copy {rel_path}: {e}")
else:
files_skipped += 1
return files_updated, files_skipped
def _backup_plugins(platform_name: str, app_name: str, source_dir: str, dest_dir: Optional[str] = None) -> bool:
"""
Backs up plugins from source directory to a temporary location.
Issue #113: Preserve plugins during updates (FALLBACK METHOD - rarely used now)
Returns True if backup was successful, False otherwise.
"""
plugins_src = os.path.join(source_dir, "plugins")
if not os.path.exists(plugins_src):
print("No plugins directory to backup.")
return False
# Create a temporary backup location (outside app dir so it won't be wiped)
import tempfile
backup_temp = tempfile.mkdtemp(prefix=f"{app_name}_plugins_backup_")
plugins_dest = os.path.join(backup_temp, "plugins")
try:
shutil.copytree(plugins_src, plugins_dest)
plugin_count = sum(1 for f in os.listdir(plugins_dest))
print(f"Backed up {plugin_count} plugin(s) to: {plugins_dest}")
# Store backup path in a marker file for restoration
marker_path = os.path.join(backup_temp, ".plugins_backup_marker")
with open(marker_path, "w") as f:
f.write(f"plugins_dir={plugins_dest}\n")
f.write(f"target_dir={source_dir}\n")
return True
except Exception as e:
print(f"Failed to backup plugins: {e}")
# Cleanup failed backup attempt
try:
shutil.rmtree(backup_temp, onexc=_remove_readonly)
except Exception:
pass
return False
def _restore_plugins(platform_name: str, app_name: str, source_dir: str) -> bool:
"""
Restores plugins from temporary backup to the application directory.
Issue #113: Preserve plugins during updates
Returns True if restoration was successful, False otherwise.
"""
import tempfile
# Find all plugin backup temp directories
temp_dir = tempfile.gettempdir()
backups_found = []
try:
for entry in os.listdir(temp_dir):
if entry.startswith(f"{app_name}_plugins_backup_"):
backup_path = os.path.join(temp_dir, entry)
marker_path = os.path.join(backup_path, ".plugins_backup_marker")
if os.path.exists(marker_path):
backups_found.append((backup_path, entry))
except Exception as e:
print(f"Error searching for plugin backups: {e}")
if not backups_found:
print("No plugin backups found to restore.")
return False
# Restore from all found backups (in case multiple exist)
plugins_restored = 0
for backup_path, entry_name in sorted(backups_found):
try:
marker_path = os.path.join(backup_path, ".plugins_backup_marker")
# Parse marker file to get original paths
backup_info = {}
with open(marker_path, "r") as f:
for line in f:
if "=" in line:
key, value = line.strip().split("=", 1)
backup_info[key] = value
plugins_src = backup_info.get("plugins_dir", os.path.join(backup_path, "plugins"))
# Create plugins directory if it doesn't exist
target_plugins_dir = os.path.join(source_dir, "plugins")
os.makedirs(target_plugins_dir, exist_ok=True)
# Copy each plugin to target
if os.path.exists(plugins_src):
for item in os.listdir(plugins_src):
src_item = os.path.join(plugins_src, item)
dst_item = os.path.join(target_plugins_dir, item)
if os.path.isdir(src_item):
if os.path.exists(dst_item):
shutil.rmtree(dst_item, onexc=_remove_readonly)
shutil.copytree(src_item, dst_item)
else:
shutil.copy2(src_item, dst_item)
plugins_restored += 1
# Cleanup backup after successful restore
try:
shutil.rmtree(backup_path, onexc=_remove_readonly)
print(f"Cleaned up backup directory: {entry_name}")
except Exception as e:
print(f"Warning: Could not cleanup backup: {e}")
except Exception as e:
print(f"Failed to restore plugins from {entry_name}: {e}")
if plugins_restored > 0:
print(f"Restored {plugins_restored} plugin(s) to: {os.path.join(source_dir, 'plugins')}")
return True
return False
def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, finish_update: bool) -> None:
"""
Completes a pending application update if '--finish-update' is present in the command-line arguments.
Issue #113: Added smart selective update (only replaces changed files).
"""
if not finish_update:
@@ -748,12 +562,6 @@ def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, fi
primary_update_folder = update_folders[0]
target_updater_folder = os.path.join(primary_update_folder, f"{app_name}-darwin") if platform_name == 'darwin' else primary_update_folder
# 2a. Perform SMART SELECTIVE UPDATE (Issue #113)
target_base = get_app_dir()
print(f"Performing selective update from: {primary_update_folder}")
files_updated, files_skipped = _smart_selective_update(primary_update_folder, target_base)
print(f"Selective update complete: {files_updated} files updated, {files_skipped} files skipped")
# 3. Wait for updater process to exit
print(f"Waiting for {app_name}_updater to exit...")
@@ -824,8 +632,6 @@ def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, fi
else:
print(f"Failed to delete update folder '{folder}' after 3 attempts: {e}")
# Note: _restore_plugins() no longer needed - selective update preserves plugins automatically
if "--finish-update" in sys.argv:
sys.argv.remove("--finish-update")