From 2554523f90cd72b8c9a669abf9e141ac74eac16a Mon Sep 17 00:00:00 2001 From: rabea-Al Date: Sat, 4 Oct 2025 21:00:23 +0800 Subject: [PATCH 01/18] Add automatic sidebar sync with canvas updates --- .../ComponentPreviewWidget.tsx | 50 ++++++++++++++++++- src/components/XircuitsBodyWidget.tsx | 7 +++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/component_info_sidebar/ComponentPreviewWidget.tsx b/src/component_info_sidebar/ComponentPreviewWidget.tsx index a2e406b7..dd8c3f1c 100644 --- a/src/component_info_sidebar/ComponentPreviewWidget.tsx +++ b/src/component_info_sidebar/ComponentPreviewWidget.tsx @@ -14,6 +14,7 @@ import { collectParamIO } from './portPreview'; import { IONodeTree } from './IONodeTree'; import type { IONode } from './portPreview'; import { commandIDs } from "../commands/CommandIDs"; +import { canvasUpdatedSignal } from '../components/XircuitsBodyWidget'; export interface IComponentInfo { name: string; @@ -156,7 +157,7 @@ class OverviewSection extends ReactWidget { } setModel(m: IComponentInfo | null) { this._model = m; - this.update(); + this.update(); } render(): JSX.Element { if (!this._model) { @@ -321,6 +322,7 @@ export class ComponentPreviewWidget extends SidePanel { const shell = this._app.shell as ILabShell; shell.expandRight(); shell.activateById(this.id); + this._bindCanvasListener(); } private _computeToolbarState(): ToolbarState { @@ -357,8 +359,52 @@ export class ComponentPreviewWidget extends SidePanel { canOpenScript: !!m.node && !isStartFinish, canCenter: !!(m.node && m.engine), canOpenWorkflow: nodeType === 'xircuits_workflow', - canCollapse: !isStartFinish + canCollapse: !isStartFinish + }; + } + + private _isListening = false; + + private _bindCanvasListener(): void { + if (this._isListening || this.isDisposed) return; + + const onCanvasUpdate = () => { + const engine = this._model?.engine; + const currentNode = this._model?.node; + if (!engine || !currentNode) return; + + // Skip updating sidebar if a link is still being dragged (incomplete connection) + const hasUnfinishedLink = Object.values(engine.getModel()?.getLinks?.() ?? {}).some( + (link: any) => !link.getTargetPort?.() + ); + if (hasUnfinishedLink) return; + + // Refresh node reference in case the model recreated it after a change + const id = currentNode.getID?.(); + const latestNode = engine.getModel?.().getNodes?.().find(n => n.getID?.() === id); + if (latestNode && latestNode !== currentNode) { + this._model!.node = latestNode; + } + + try { + const { inputs = [], outputs = [] } = collectParamIO(this._model!.node as any); + this._inputs.setData(inputs); + this._outputs.setData(outputs); + } catch (err) { + console.warn('[Sidebar] Failed to collect I/O, keeping previous state:', err); + } + + + this._topbar?.update(); }; + + canvasUpdatedSignal.connect(onCanvasUpdate, this); + this._isListening = true; + + this.disposed.connect(() => { + canvasUpdatedSignal.disconnect(onCanvasUpdate, this); + this._isListening = false; + }); } private _navigate(step: -1 | 1) { diff --git a/src/components/XircuitsBodyWidget.tsx b/src/components/XircuitsBodyWidget.tsx index c33f7e72..b6e1e5d1 100644 --- a/src/components/XircuitsBodyWidget.tsx +++ b/src/components/XircuitsBodyWidget.tsx @@ -206,6 +206,10 @@ const ZoomControls = styled.div<{visible: boolean}>` } `; +export type CanvasUpdatedPayload = { reason: 'content'; }; + +export const canvasUpdatedSignal = new Signal(window); + export const BodyWidget: FC = ({ context, xircuitsApp, @@ -471,6 +475,7 @@ export const BodyWidget: FC = ({ return () => clearTimeout(timeout); }, linksUpdated: (event) => { + canvasUpdatedSignal.emit({ reason: 'content' }); const timeout = setTimeout(() => { event.link.registerListener({ sourcePortChanged: () => { @@ -494,6 +499,8 @@ export const BodyWidget: FC = ({ xircuitsApp.getDiagramEngine().setModel(deserializedModel); clearSearchFlags(); + canvasUpdatedSignal.emit({ reason: 'content' }); + // On the first load, clear undo history and register global engine listeners if (initialRender.current) { currentContext.model.sharedModel.clearUndoHistory(); From 8e76f8b029afdb94991f83c5bd04e57b291ee921 Mon Sep 17 00:00:00 2001 From: MFA-X-AI Date: Tue, 7 Oct 2025 01:37:38 +0900 Subject: [PATCH 02/18] add core backup function --- xircuits/library/__init__.py | 3 +- xircuits/library/core_libs.py | 16 ++ xircuits/library/install_fetch_library.py | 8 +- xircuits/library/update_library.py | 231 ++++++++++++++++++++-- 4 files changed, 233 insertions(+), 25 deletions(-) create mode 100644 xircuits/library/core_libs.py diff --git a/xircuits/library/__init__.py b/xircuits/library/__init__.py index 31f7ad7e..06ee68f6 100644 --- a/xircuits/library/__init__.py +++ b/xircuits/library/__init__.py @@ -1,4 +1,5 @@ from .list_library import list_component_library from .install_fetch_library import install_library, fetch_library, uninstall_library from .create_library import create_or_update_library -from .update_library import update_library \ No newline at end of file +from .update_library import update_library +from .core_libs import CORE_LIBS, is_core_library diff --git a/xircuits/library/core_libs.py b/xircuits/library/core_libs.py new file mode 100644 index 00000000..00bfa5a8 --- /dev/null +++ b/xircuits/library/core_libs.py @@ -0,0 +1,16 @@ +from typing import FrozenSet + +# Core component libraries that are bundled with the xircuits wheel +CORE_LIBS: FrozenSet[str] = frozenset({ + "xai_controlflow", + "xai_events", + "xai_template", + "xai_utils", +}) + +def is_core_library(library_name: str) -> bool: + """Check if a library name refers to a core component.""" + normalized = library_name.strip().lower() + if not normalized.startswith("xai_"): + normalized = f"xai_{normalized}" + return normalized in CORE_LIBS \ No newline at end of file diff --git a/xircuits/library/install_fetch_library.py b/xircuits/library/install_fetch_library.py index 5bd498ab..3ba03050 100644 --- a/xircuits/library/install_fetch_library.py +++ b/xircuits/library/install_fetch_library.py @@ -1,6 +1,8 @@ import shutil from pathlib import Path +from .core_libs import is_core_library + from xircuits.utils.file_utils import is_valid_url, is_empty from xircuits.utils.requirements_utils import read_requirements_for_library from xircuits.utils.git_toml_manager import ( @@ -19,10 +21,6 @@ from ..handlers.request_remote import request_remote_library from ..handlers.request_folder import clone_from_github_url - -CORE_LIBS = {"xai_events", "xai_template", "xai_controlflow", "xai_utils"} - - def get_component_library_path(library_name: str) -> str: """ If a URL is provided, clone to xai_components/xai_. @@ -149,7 +147,7 @@ def uninstall_library(library_name: str) -> str: raw = "xai_" + raw short = raw.split("/")[-1] - if short in CORE_LIBS: + if is_core_library(short): raise RuntimeError(f"'{short}' is a core library and cannot be uninstalled.") lib_path = resolve_library_dir(short) diff --git a/xircuits/library/update_library.py b/xircuits/library/update_library.py index 9d1f3915..f03abda7 100644 --- a/xircuits/library/update_library.py +++ b/xircuits/library/update_library.py @@ -6,6 +6,9 @@ from dataclasses import dataclass from pathlib import Path from typing import List, Optional, Set +from importlib_resources import files, as_file + +from .core_libs import is_core_library from xircuits.utils.pathing import ( resolve_working_dir, @@ -50,38 +53,162 @@ def update_library( install_deps: bool = True, ) -> str: """ - Safely update an installed component library (e.g., "gradio"). + Update an installed component library (core or regular). - Defaults: - - Keeps any local-only files/dirs as-is (no deletions) unless prune=True. - - Overwrites changed files, backing up the previous version in-place as *.YYYYmmdd-HHMMSS.bak. - - Updates per-library extra and installs requirements (unless disabled). + Core libraries (xai_events, xai_template, xai_controlflow, xai_utils, base.py) + are updated from the installed xircuits wheel. + + Regular libraries are updated from their git repository. Args: - library_name: "gradio", "xai_gradio", etc. (normalized internally) - repo: Optional repository URL override (persists into pyproject if not dry-run). - ref: Optional tag/branch/commit to update to. + library_name: "gradio", "xai_events", "base.py", etc. (normalized internally) + repo: Optional repository URL override (ignored for core libs) + ref: Optional tag/branch/commit to update to (ignored for core libs) dry_run: If True, compute actions and print a unified diff (no files changed). - Also writes a combined diff file alongside the library directory. prune: If True, also archive local-only files/dirs (rename to *.bak). install_deps: If True (default), update per-library extra and install its requirements. - Output policy: - - Print action markers: - +++ path (added or written) - --- path (...) (backed up / deleted) - - Unchanged files are not printed. - - On dry-run, a unified diff of changes is printed and saved to disk. + Returns: + Summary message of update results. """ working_dir = resolve_working_dir() if working_dir is None: raise RuntimeError("Xircuits working directory not found. Run 'xircuits init' first.") - lib_name = normalize_library_slug(library_name) # e.g., 'xai_gradio' - dest_dir = resolve_library_dir(lib_name) # absolute path + # Check if updating base.py + normalized = library_name.strip().lower() + if normalized in ("base", "base.py"): + return _update_from_wheel( + component_name="base.py", + working_dir=working_dir, + dry_run=dry_run, + prune=prune, + ) + + # Normalize library name and check if it's a core library + lib_name = normalize_library_slug(library_name) + if is_core_library(lib_name): + return _update_from_wheel( + component_name=lib_name, + working_dir=working_dir, + dry_run=dry_run, + prune=prune, + ) + + # Regular (non-core) library update from git + return _update_from_git( + lib_name=lib_name, + working_dir=working_dir, + repo=repo, + ref=ref, + dry_run=dry_run, + prune=prune, + install_deps=install_deps, + ) + +def _update_from_wheel( + component_name: str, + working_dir: Path, + dry_run: bool, + prune: bool, +) -> str: + """ + Update a core component from the installed xircuits wheel. + """ + dest_base = working_dir / "xai_components" + if not dest_base.exists(): + raise RuntimeError(f"xai_components directory not found at {working_dir}") + + timestamp = time.strftime("%Y%m%d-%H%M%S") + is_base_file = component_name == "base.py" + display_name = component_name + + print(f"Updating core component: {display_name} (from installed wheel)") + + temp_dir = Path(tempfile.mkdtemp(prefix=f"update_core_{component_name.replace('.', '_')}_")) + + try: + # Extract from wheel to temp directory + if is_base_file: + _extract_base_py_from_wheel(temp_dir) + src_path = temp_dir / "base.py" + dst_path = dest_base / "base.py" + else: + _extract_core_lib_from_wheel(component_name, temp_dir) + src_path = temp_dir / component_name + dst_path = dest_base / component_name + + if not src_path.exists(): + raise FileNotFoundError(f"{display_name} not found in installed wheel") + + # Ensure destination exists + if not dst_path.exists(): + if is_base_file: + raise FileNotFoundError(f"{display_name} does not exist locally. Run 'xircuits init' first.") + else: + raise FileNotFoundError(f"{display_name} does not exist locally. This is unexpected for a core library.") + + # Sync files + if is_base_file: + report = _sync_single_file(src_path, dst_path, dry_run, timestamp) + else: + report = _sync_with_backups( + source_root=src_path, + destination_root=dst_path, + dry_run=dry_run, + prune=prune, + timestamp=timestamp, + ) + + # Generate diff for dry-run + if dry_run: + if is_base_file and report.updated: + diff_text = _unified_diff_for_pair(src_path, dst_path, Path("base.py")) + if diff_text.strip(): + print("\n--- DRY-RUN DIFF ---") + print(diff_text.rstrip()) + elif not is_base_file: + diff_text = _build_combined_diff( + src_path, dst_path, + added=report.added, + updated=report.updated, + deleted=report.deleted + ) + if diff_text.strip(): + diff_path = dst_path / f"{component_name}.update.{timestamp}.dry-run.diff.txt" + diff_path.write_text(diff_text, encoding="utf-8") + print("\n--- DRY-RUN DIFF ---") + print(diff_text.rstrip()) + print(f"\n(Wrote diff file to: {diff_path})") + + summary = ( + f"{display_name} update " + f"(added: {len(report.added)}, updated: {len(report.updated)}, " + f"deleted: {len(report.deleted)}, unchanged: {len(report.unchanged)})" + ) + return summary + + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +def _update_from_git( + lib_name: str, + working_dir: Path, + repo: Optional[str], + ref: Optional[str], + dry_run: bool, + prune: bool, + install_deps: bool, +) -> str: + """ + Update a regular component library from its git repository. + (Original update_library logic) + """ + dest_dir = resolve_library_dir(lib_name) if not dest_dir.exists() or not dest_dir.is_dir(): raise FileNotFoundError( - f"Library '{lib_name}' not found at {dest_dir}. Try 'xircuits install {library_name}'." + f"Library '{lib_name}' not found at {dest_dir}. Try 'xircuits install {lib_name}'." ) timestamp = time.strftime("%Y%m%d-%H%M%S") @@ -89,7 +216,7 @@ def update_library( source_spec = _resolve_source_spec(lib_name, repo, ref) if not source_spec or not source_spec.repo_url: raise RuntimeError( - f"Could not resolve a repository URL for '{library_name}'. " + f"Could not resolve a repository URL for '{lib_name}'. " "Ensure it was installed (so metadata exists) or present in your index.json." ) @@ -185,6 +312,72 @@ def update_library( shutil.rmtree(temp_repo_dir, ignore_errors=True) +# ========== Wheel Extraction Helpers ========== + +def _extract_core_lib_from_wheel(lib_name: str, dest_dir: Path) -> None: + """ + Extract a core component library directory from the installed wheel. + """ + try: + lib_ref = files('xai_components') / lib_name + with as_file(lib_ref) as source_path: + if not source_path.exists(): + raise FileNotFoundError(f"{lib_name} not found in wheel") + shutil.copytree(source_path, dest_dir / lib_name, dirs_exist_ok=True) + except Exception as e: + raise RuntimeError(f"Failed to extract {lib_name} from wheel: {e}") + + +def _extract_base_py_from_wheel(dest_dir: Path) -> None: + """ + Extract base.py from the installed wheel. + """ + try: + base_ref = files('xai_components') / 'base.py' + with as_file(base_ref) as source_path: + if not source_path.exists(): + raise FileNotFoundError("base.py not found in wheel") + shutil.copy2(source_path, dest_dir / 'base.py') + except Exception as e: + raise RuntimeError(f"Failed to extract base.py from wheel: {e}") + + +def _sync_single_file(src_file: Path, dst_file: Path, dry_run: bool, timestamp: str) -> SyncReport: + """ + Sync a single file with backup support. + """ + added: List[str] = [] + updated: List[str] = [] + deleted: List[str] = [] + unchanged: List[str] = [] + + rel_path = dst_file.name + + if not dst_file.exists(): + print(f"+++ {rel_path}") + if not dry_run: + dst_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src_file, dst_file) + added.append(rel_path) + elif _files_equal(src_file, dst_file): + unchanged.append(rel_path) + else: + if dry_run: + backup_name = _backup_in_place(dst_file, timestamp, dry_run) + print(f"--- {rel_path} (would backup as: {backup_name})") + print(f"+++ {rel_path}") + else: + backup_name = _backup_in_place(dst_file, timestamp, dry_run) + print(f"--- {rel_path} (backup: {backup_name})") + print(f"+++ {rel_path}") + shutil.copy2(src_file, dst_file) + updated.append(rel_path) + + return SyncReport(added=added, updated=updated, deleted=deleted, unchanged=unchanged) + + +# ========== Git Update Helpers ========== + def _resolve_source_spec( lib_name: str, repo_override: Optional[str], From 8df5a9d01dd7d0f2db88bac762ce75fae99fa2af Mon Sep 17 00:00:00 2001 From: MFA-X-AI Date: Wed, 8 Oct 2025 20:52:19 +0900 Subject: [PATCH 03/18] add update all feature --- xircuits/library/update_library.py | 205 ++++++++++++++++++++++++++++- xircuits/start_xircuits.py | 69 ++++++++-- 2 files changed, 258 insertions(+), 16 deletions(-) diff --git a/xircuits/library/update_library.py b/xircuits/library/update_library.py index f03abda7..d571f298 100644 --- a/xircuits/library/update_library.py +++ b/xircuits/library/update_library.py @@ -8,6 +8,7 @@ from typing import List, Optional, Set from importlib_resources import files, as_file +from xircuits.utils.pathing import resolve_working_dir, components_base_dir from .core_libs import is_core_library from xircuits.utils.pathing import ( @@ -51,6 +52,7 @@ def update_library( dry_run: bool = False, prune: bool = False, install_deps: bool = True, + use_latest: bool = False, ) -> str: """ Update an installed component library (core or regular). @@ -67,6 +69,7 @@ def update_library( dry_run: If True, compute actions and print a unified diff (no files changed). prune: If True, also archive local-only files/dirs (rename to *.bak). install_deps: If True (default), update per-library extra and install its requirements. + use_latest: If True, ignore metadata ref and pull latest from default branch. Returns: Summary message of update results. @@ -104,6 +107,7 @@ def update_library( dry_run=dry_run, prune=prune, install_deps=install_deps, + use_latest=use_latest, ) def _update_from_wheel( @@ -200,6 +204,7 @@ def _update_from_git( dry_run: bool, prune: bool, install_deps: bool, + use_latest: bool = False, ) -> str: """ Update a regular component library from its git repository. @@ -213,7 +218,7 @@ def _update_from_git( timestamp = time.strftime("%Y%m%d-%H%M%S") - source_spec = _resolve_source_spec(lib_name, repo, ref) + source_spec = _resolve_source_spec(lib_name, repo, ref, use_latest) if not source_spec or not source_spec.repo_url: raise RuntimeError( f"Could not resolve a repository URL for '{lib_name}'. " @@ -382,12 +387,15 @@ def _resolve_source_spec( lib_name: str, repo_override: Optional[str], user_ref: Optional[str], + use_latest: bool = False, ) -> Optional[SourceSpec]: """ Priority: 0) explicit repo override (CLI/API 'repo=') 1) pyproject.toml [tool.xircuits.components] entry (source + tag/rev) 2) manifest index via get_remote_config() + + If use_latest=True, ignore metadata ref and use user_ref (or None for default branch). """ # use repo url if specified if repo_override: @@ -395,7 +403,13 @@ def _resolve_source_spec( # pyproject metadata source_url, meta_ref = read_component_metadata_entry(lib_name) - desired_ref = user_ref or meta_ref + + # Determine ref: use_latest bypasses metadata ref + if use_latest: + desired_ref = user_ref # None means default branch + else: + desired_ref = user_ref or meta_ref + if source_url: return SourceSpec(repo_url=source_url, desired_ref=desired_ref) @@ -674,3 +688,190 @@ def _build_combined_diff( out.append(diff) return "\n".join(out) + +# ---------- Update All functionality ---------- + +def update_all_libraries( + dry_run: bool = False, + prune: bool = False, + install_deps: bool = True, + core_only: bool = False, + remote_only: bool = False, + exclude: List[str] = None, + respect_refs: bool = False, +) -> dict: + """ + Update all installed component libraries found in xai_components/. + + Args: + dry_run: Preview changes without modifying files + prune: Remove local-only files during update + install_deps: Install/update Python dependencies + core_only: Only update core libraries + remote_only: Only update non-core libraries + exclude: List of library names to skip + respect_refs: Honor pinned refs in metadata (default: pull latest) + + Returns: + Dict with 'success', 'failed', 'skipped' lists and summary stats + """ + + # Validate conflicting flags + if core_only and remote_only: + raise ValueError("Cannot specify both --core-only and --remote-only") + + working_dir = resolve_working_dir() + if working_dir is None: + raise RuntimeError("Xircuits working directory not found. Run 'xircuits init' first.") + + exclude_set = set((exclude or [])) + exclude_set = {normalize_library_slug(x) for x in exclude_set} + + results = { + "success": [], + "failed": [], + "skipped": [] + } + + # Discover libraries to update + libraries_to_update = _discover_updateable_libraries( + working_dir=working_dir, + core_only=core_only, + remote_only=remote_only, + exclude=exclude_set + ) + + if not libraries_to_update: + print("No libraries found to update.") + return results + + print(f"Found {len(libraries_to_update)} {'library' if len(libraries_to_update) == 1 else 'libraries'} to update") + if dry_run: + print("DRY-RUN MODE: No files will be modified\n") + print() + + # Update each library + for lib_name in sorted(libraries_to_update): + try: + print(f"{'='*60}") + print(f"Updating: {lib_name}") + print(f"{'='*60}") + + # For --all without --respect-refs, pull latest by setting use_latest=True + message = update_library( + library_name=lib_name, + repo=None, + ref=None, + dry_run=dry_run, + prune=prune, + install_deps=install_deps, + use_latest=not respect_refs, + ) + + results["success"].append((lib_name, message)) + print(f"✓ {lib_name}: {message}\n") + + except Exception as e: + error_msg = str(e) + results["failed"].append((lib_name, error_msg)) + print(f"✗ {lib_name}: Failed - {error_msg}\n") + # Continue to next library + + # Print summary + _print_update_all_summary(results, dry_run) + + return results + +def _discover_updateable_libraries( + working_dir: Path, + core_only: bool, + remote_only: bool, + exclude: set +) -> List[str]: + """ + Scan xai_components directory and return list of updateable library names. + """ + + base_dir = components_base_dir(working_dir) + if not base_dir.exists(): + return [] + + libraries = [] + + # Check base.py + base_py = base_dir / "base.py" + if base_py.exists() and base_py.is_file(): + if not remote_only and "base.py" not in exclude: + if core_only or not remote_only: + libraries.append("base.py") + + # Scan xai_* directories + for item in base_dir.glob("xai_*"): + if not item.is_dir(): + continue + + # Must have __init__.py to be valid + if not (item / "__init__.py").exists(): + continue + + lib_name = item.name + + # Check exclusions + if lib_name in exclude: + continue + + # Check core/remote filters + is_core = is_core_library(lib_name) + if core_only and not is_core: + continue + if remote_only and is_core: + continue + + libraries.append(lib_name) + + return libraries + + +def _print_update_all_summary(results: dict, dry_run: bool): + """ + Print a formatted summary of update results. + """ + print() + print("="*60) + print("Update All Summary") + print("="*60) + print() + + if results["success"]: + print("✓ SUCCEEDED:") + for lib_name, message in results["success"]: + print(f" {lib_name:20} {message}") + print() + + if results["failed"]: + print("✗ FAILED:") + for lib_name, error in results["failed"]: + # Truncate long errors + error_display = error if len(error) <= 60 else error[:57] + "..." + print(f" {lib_name:20} {error_display}") + print() + + if results["skipped"]: + print("⊘ SKIPPED:") + for lib_name, reason in results["skipped"]: + print(f" {lib_name:20} {reason}") + print() + + # Summary counts + total = len(results["success"]) + len(results["failed"]) + len(results["skipped"]) + summary_parts = [] + if results["success"]: + summary_parts.append(f"{len(results['success'])} succeeded") + if results["failed"]: + summary_parts.append(f"{len(results['failed'])} failed") + if results["skipped"]: + summary_parts.append(f"{len(results['skipped'])} skipped") + + mode_suffix = " (dry-run)" if dry_run else "" + print(f"{', '.join(summary_parts)}{mode_suffix}") + print("="*60) diff --git a/xircuits/start_xircuits.py b/xircuits/start_xircuits.py index 79f3c7d6..b0b962c3 100644 --- a/xircuits/start_xircuits.py +++ b/xircuits/start_xircuits.py @@ -9,7 +9,7 @@ from .library import list_component_library, install_library, fetch_library, uninstall_library from .library.index_config import refresh_index -from .library.update_library import update_library +from .library.update_library import update_library, update_all_libraries from .compiler import compile, recursive_compile from xircuits.handlers.config import get_config @@ -146,16 +146,44 @@ def cmd_sync(args, extra_args=[]): sync_xai_components() def cmd_update_library(args, extra_args=[]): - - message = update_library( - library_name=args.library_name, - repo=args.repo, - ref=args.ref, - dry_run=args.dry_run, - prune=args.prune, - install_deps=args.install_deps, - ) - print(message) + if args.all: + + # Parse exclude list + exclude_list = [] + if args.exclude: + exclude_list = [x.strip() for x in args.exclude.split(',') if x.strip()] + + # Validate conflicting flags + if args.core_only and args.remote_only: + print("Error: Cannot specify both --core-only and --remote-only") + return + + try: + results = update_all_libraries( + dry_run=args.dry_run, + prune=args.prune, + install_deps=args.install_deps, + core_only=args.core_only, + remote_only=args.remote_only, + exclude=exclude_list, + respect_refs=args.respect_refs, + ) + + except Exception as e: + print(f"Error: {e}") + return + else: + # single-library update + message = update_library( + library_name=args.library_name, + repo=args.repo, + ref=args.ref, + dry_run=args.dry_run, + prune=args.prune, + install_deps=args.install_deps, + # use_latest defaults to False for single library updates + ) + print(message) def cmd_run(args, extra_args=[]): original_cwd = args.original_cwd @@ -276,15 +304,28 @@ def main(): update_parser = subparsers.add_parser( 'update', help='Update a component library with in-place .bak backups.' ) - update_parser.add_argument('library_name', type=str, help='Library to update (e.g., flask)') + update_parser.add_argument('library_name', nargs='?', type=str, + help='Library to update (e.g., flask). Omit with --all.') update_parser.add_argument('--repo', type=str, default=None, help='Override source repository URL') update_parser.add_argument('--ref', type=str, default=None, help='Tag/branch/commit to update to') update_parser.add_argument('--dry-run', action='store_true', help='Preview only; no changes') update_parser.add_argument('--prune', action='store_true', help='Prune local-only files/dirs (rename to .bak)') update_parser.add_argument('--install-deps', nargs='?', const=True, default=True, - type=lambda s: str(s).lower() not in ('0','false','no','off'), - help='Install/update Python deps (default true). Pass false to disable.') + type=lambda s: str(s).lower() not in ('0','false','no','off'), + help='Install/update Python deps (default true). Pass false to disable.') + + update_parser.add_argument('--all', action='store_true', + help='Update all installed component libraries') + update_parser.add_argument('--core-only', action='store_true', + help='Update only core libraries (xai_events, xai_template, etc.)') + update_parser.add_argument('--remote-only', action='store_true', + help='Update only remote (non-core) libraries') + update_parser.add_argument('--exclude', type=str, default='', + help='Comma-separated list of libraries to exclude (e.g., gradio,opencv)') + update_parser.add_argument('--respect-refs', action='store_true', + help='Honor pinned refs in metadata (default: pull latest for --all)') + update_parser.set_defaults(func=cmd_update_library) # 'run' command. From 4a70e72d73c29493b4c07b3c4b0a1c695cb44760 Mon Sep 17 00:00:00 2001 From: MFA-X-AI Date: Wed, 8 Oct 2025 21:55:46 +0900 Subject: [PATCH 04/18] throw message error if running update without lib name --- xircuits/start_xircuits.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/xircuits/start_xircuits.py b/xircuits/start_xircuits.py index b0b962c3..fee9e4bc 100644 --- a/xircuits/start_xircuits.py +++ b/xircuits/start_xircuits.py @@ -174,6 +174,10 @@ def cmd_update_library(args, extra_args=[]): return else: # single-library update + if not args.library_name: + print("Error: library_name is required when not using --all") + return + message = update_library( library_name=args.library_name, repo=args.repo, From fb43ea3550418035379556214927eb667bac6b24 Mon Sep 17 00:00:00 2001 From: MFA-X-AI Date: Wed, 8 Oct 2025 22:16:10 +0900 Subject: [PATCH 05/18] allow no-overwrite update flag, skip diff for .xircuits files --- xircuits/library/update_library.py | 248 +++++++++++++++-------------- xircuits/start_xircuits.py | 5 +- 2 files changed, 135 insertions(+), 118 deletions(-) diff --git a/xircuits/library/update_library.py b/xircuits/library/update_library.py index d571f298..3904cd9b 100644 --- a/xircuits/library/update_library.py +++ b/xircuits/library/update_library.py @@ -53,6 +53,7 @@ def update_library( prune: bool = False, install_deps: bool = True, use_latest: bool = False, + no_overwrite: bool = False, ) -> str: """ Update an installed component library (core or regular). @@ -70,6 +71,7 @@ def update_library( prune: If True, also archive local-only files/dirs (rename to *.bak). install_deps: If True (default), update per-library extra and install its requirements. use_latest: If True, ignore metadata ref and pull latest from default branch. + no_overwrite: If True, skip updating files with local modifications. Returns: Summary message of update results. @@ -86,6 +88,7 @@ def update_library( working_dir=working_dir, dry_run=dry_run, prune=prune, + no_overwrite=no_overwrite, ) # Normalize library name and check if it's a core library @@ -96,6 +99,7 @@ def update_library( working_dir=working_dir, dry_run=dry_run, prune=prune, + no_overwrite=no_overwrite, ) # Regular (non-core) library update from git @@ -108,6 +112,7 @@ def update_library( prune=prune, install_deps=install_deps, use_latest=use_latest, + no_overwrite=no_overwrite, ) def _update_from_wheel( @@ -115,6 +120,7 @@ def _update_from_wheel( working_dir: Path, dry_run: bool, prune: bool, + no_overwrite: bool = False, ) -> str: """ Update a core component from the installed xircuits wheel. @@ -154,7 +160,7 @@ def _update_from_wheel( # Sync files if is_base_file: - report = _sync_single_file(src_path, dst_path, dry_run, timestamp) + report = _sync_single_file(src_path, dst_path, dry_run, timestamp, no_overwrite) # <-- ADD no_overwrite else: report = _sync_with_backups( source_root=src_path, @@ -162,6 +168,7 @@ def _update_from_wheel( dry_run=dry_run, prune=prune, timestamp=timestamp, + no_overwrite=no_overwrite, ) # Generate diff for dry-run @@ -205,6 +212,7 @@ def _update_from_git( prune: bool, install_deps: bool, use_latest: bool = False, + no_overwrite: bool = False, ) -> str: """ Update a regular component library from its git repository. @@ -225,26 +233,30 @@ def _update_from_git( "Ensure it was installed (so metadata exists) or present in your index.json." ) - print( - f"Updating {lib_name} from {source_spec.repo_url} " - f"{'(ref='+source_spec.desired_ref+')' if source_spec.desired_ref else '(default branch)'}" - ) + print(f"Updating '{lib_name}' from {source_spec.repo_url} @ {source_spec.desired_ref or 'default'}...") temp_repo_dir = Path(tempfile.mkdtemp(prefix=f"update_{lib_name}_")) try: - git_clone_shallow(source_spec.repo_url, temp_repo_dir) - if source_spec.desired_ref: - git_checkout_ref(temp_repo_dir, source_spec.desired_ref) + clone_from_github_url( + library_url=source_spec.repo_url, + target_dir=str(temp_repo_dir), + ref=source_spec.desired_ref + ) - repo_url_final, resolved_ref, is_tag = get_git_metadata(str(temp_repo_dir)) - src_dir = _select_library_source_dir(temp_repo_dir, lib_name) + temp_lib_dir = temp_repo_dir / lib_name + if not temp_lib_dir.exists(): + raise FileNotFoundError( + f"No '{lib_name}' subdirectory in cloned repository. " + "Ensure the library name matches the folder in the repo." + ) - report = _sync_with_backups( - source_root=src_dir, + sync_report = _sync_with_backups( + source_root=temp_lib_dir, destination_root=dest_dir, dry_run=dry_run, prune=prune, timestamp=timestamp, + no_overwrite=no_overwrite, ) # On DRY-RUN: show and save a unified diff of planned changes. @@ -252,10 +264,10 @@ def _update_from_git( # ever touch '/dev/null'. For adds/deletes we diff against an empty side. if dry_run: diff_text = _build_combined_diff( - src_dir, dest_dir, - added=report.added, - updated=report.updated, - deleted=report.deleted + temp_lib_dir, dest_dir, + added=sync_report.added, + updated=sync_report.updated, + deleted=sync_report.deleted ) if diff_text.strip(): diff_path = dest_dir / f"{lib_name}.update.{timestamp}.dry-run.diff.txt" @@ -266,57 +278,26 @@ def _update_from_git( # Update pyproject metadata / deps (skipped during dry-run) if not dry_run: - try: - record_component_metadata( - library_name=lib_name, # normalizes to xai-* - member_path=str(dest_dir), - repo_url=repo_url_final or source_spec.repo_url, - ref=resolved_ref or source_spec.desired_ref or "latest", - is_tag=is_tag, - ) - except Exception as e: - print(f"Warning: could not update pyproject metadata: {e}") - - # Requirements / extras install - try: - reqs = read_requirements_for_library(dest_dir) - except Exception as e: - reqs = [] - print(f"Warning: could not read requirements for {lib_name}: {e}") - - # Always refresh the per-library extra + meta extra on update (safe even if no install) - try: - set_library_extra(lib_name, reqs) - rebuild_meta_extra("xai-components") - except Exception as e: - print(f"Warning: could not update optional-dependencies for {lib_name}: {e}") + if repo: + write_component_metadata_entry(lib_name, repo, source_spec.desired_ref) if install_deps: - try: - if reqs: - print(f"Installing Python dependencies for {lib_name}...") - install_specs(reqs) - print(f"✓ Dependencies for {lib_name} installed.") - else: - print(f"No requirements.txt entries for {lib_name}; nothing to install.") - except Exception as e: - print(f"Warning: installing dependencies for {lib_name} failed:{e}".rstrip()) - - try: - regenerate_lock_file() - except Exception as e: - print(f"Warning: could not regenerate lock file: {e}") + reqs_list = read_requirements_for_library(dest_dir) + if reqs_list: + print(f"Installing requirements for {lib_name}...") + install_per_library_extra(lib_name, reqs_list) + else: + print(f"No requirements.txt found for {lib_name}; skipping dependency install.") summary = ( f"{lib_name} update " - f"(added: {len(report.added)}, updated: {len(report.updated)}, " - f"deleted: {len(report.deleted)}, unchanged: {len(report.unchanged)})" + f"(added: {len(sync_report.added)}, updated: {len(sync_report.updated)}, " + f"deleted: {len(sync_report.deleted)}, unchanged: {len(sync_report.unchanged)})" ) return summary finally: shutil.rmtree(temp_repo_dir, ignore_errors=True) - # ========== Wheel Extraction Helpers ========== def _extract_core_lib_from_wheel(lib_name: str, dest_dir: Path) -> None: @@ -347,7 +328,7 @@ def _extract_base_py_from_wheel(dest_dir: Path) -> None: raise RuntimeError(f"Failed to extract base.py from wheel: {e}") -def _sync_single_file(src_file: Path, dst_file: Path, dry_run: bool, timestamp: str) -> SyncReport: +def _sync_single_file(src_file: Path, dst_file: Path, dry_run: bool, timestamp: str, no_overwrite: bool = False) -> SyncReport: """ Sync a single file with backup support. """ @@ -367,16 +348,21 @@ def _sync_single_file(src_file: Path, dst_file: Path, dry_run: bool, timestamp: elif _files_equal(src_file, dst_file): unchanged.append(rel_path) else: - if dry_run: + # File has local modifications + if no_overwrite: BLOCK + print(f"⊙ {rel_path} (local changes preserved)") + unchanged.append(rel_path) + elif dry_run: backup_name = _backup_in_place(dst_file, timestamp, dry_run) print(f"--- {rel_path} (would backup as: {backup_name})") print(f"+++ {rel_path}") + updated.append(rel_path) else: backup_name = _backup_in_place(dst_file, timestamp, dry_run) print(f"--- {rel_path} (backup: {backup_name})") print(f"+++ {rel_path}") shutil.copy2(src_file, dst_file) - updated.append(rel_path) + updated.append(rel_path) return SyncReport(added=added, updated=updated, deleted=deleted, unchanged=unchanged) @@ -505,80 +491,86 @@ def _sync_with_backups( dry_run: bool, prune: bool, timestamp: str, + no_overwrite: bool = False, ) -> SyncReport: """ - Perform the filesystem sync (or simulate it on dry_run). - - Prints concise markers: - +++ path - --- path (backup: ) for updated/deleted - --- path (would backup) for updated/deleted (dry-run mode) + Synchronize source_root into destination_root. + + - Added files/dirs are copied. + - Modified files: backed up then overwritten (unless no_overwrite=True). + - Local-only files: left alone unless prune=True. + - If no_overwrite=True, skip updating files that differ locally. """ - added: List[str] = [] - updated: List[str] = [] - deleted: List[str] = [] - unchanged: List[str] = [] + report = SyncReport(added=[], updated=[], deleted=[], unchanged=[]) - source_files = _walk_files(source_root) - destination_files = _walk_files(destination_root) + src_files = _gather_files_recursive(source_root) + dst_files = _gather_files_recursive(destination_root) - # Add / update - for rel in sorted(source_files, key=str): + src_rel = {p.relative_to(source_root) for p in src_files} + dst_rel = {p.relative_to(destination_root) for p in dst_files} + + added_rel = src_rel - dst_rel + common_rel = src_rel & dst_rel + local_only = dst_rel - src_rel + + # 1) Added files + for rel in sorted(added_rel): src = source_root / rel dst = destination_root / rel - path_str = rel.as_posix() + report.added.append(str(rel)) + print(f"+++ {rel}") + if not dry_run: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + # 2) Common files + for rel in sorted(common_rel): + src = source_root / rel + dst = destination_root / rel if not dst.exists(): - print(f"+++ {path_str}") - _copy_file(src, dst, dry_run) - added.append(path_str) - continue - - if _files_equal(src, dst): - unchanged.append(path_str) - continue - - if dry_run: - backup_name = _backup_in_place(dst, timestamp, dry_run) - print(f"--- {path_str} (would backup as: {backup_name})") - print(f"+++ {path_str}") + report.added.append(str(rel)) + print(f"+++ {rel}") + if not dry_run: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + elif src.is_file() and dst.is_file(): + if _files_equal(src, dst): + report.unchanged.append(str(rel)) + else: + # File differs - local modification detected + if no_overwrite: BLOCK + print(f"⊙ {rel} (local changes preserved)") + report.unchanged.append(str(rel)) + elif dry_run: + backup_name = _backup_in_place(dst, timestamp, dry_run) + report.updated.append(str(rel)) + print(f"--- {rel} (would backup as: {backup_name})") + print(f"+++ {rel}") + else: + backup_name = _backup_in_place(dst, timestamp, dry_run) + report.updated.append(str(rel)) + print(f"--- {rel} (backup: {backup_name})") + print(f"+++ {rel}") + shutil.copy2(src, dst) else: - backup_name = _backup_in_place(dst, timestamp, dry_run) - print(f"--- {path_str} (backup: {backup_name})") - print(f"+++ {path_str}") - _copy_file(src, dst, dry_run) - updated.append(path_str) + report.unchanged.append(str(rel)) - # Deletions (dest-only) — only when prune=True + # 3) Local-only files (prune if requested) if prune: - source_dirs = _walk_dirs(source_root) - destination_dirs = _walk_dirs(destination_root) - - for rel in sorted(destination_files - source_files, key=str): + for rel in sorted(local_only): dst = destination_root / rel - path_str = rel.as_posix() - if dry_run: + if dst.exists(): backup_name = _backup_in_place(dst, timestamp, dry_run) - print(f"--- {path_str} (would backup as: {backup_name})") - else: - backup_name = _backup_in_place(dst, timestamp, dry_run) - print(f"--- {path_str} (backup: {backup_name})") - deleted.append(path_str) - - # Directories only in destination — deepest first - for rel in sorted(destination_dirs - source_dirs, key=lambda p: len(p.as_posix()), reverse=True): - dst_dir = destination_root / rel - if dst_dir.exists(): - path_str = rel.as_posix() + "/" + report.deleted.append(str(rel)) if dry_run: - backup_name = _backup_in_place(dst_dir, timestamp, dry_run) - print(f"--- {path_str} (would backup as: {backup_name})") + print(f"--- {rel} (would prune as: {backup_name})") else: - backup_name = _backup_in_place(dst_dir, timestamp, dry_run) - print(f"--- {path_str} (backup: {backup_name})") - deleted.append(path_str) + print(f"--- {rel} (pruned: {backup_name})") + else: + for rel in sorted(local_only): + report.unchanged.append(str(rel)) - return SyncReport(added=added, updated=updated, deleted=deleted, unchanged=unchanged) + return report # ---------- Diff helpers (for dry-run) ---------- @@ -607,6 +599,7 @@ def _read_text(path: Optional[Path]) -> List[str]: def _unified_diff_for_pair(src: Optional[Path], dst: Optional[Path], rel: Path) -> str: """ Build a unified diff between dst (current destination, old) and src (incoming source, new). + For .xircuits files, only show a summary line instead of full diff. """ def _normalize_for_diff(lines: List[str], keep_max_blank_run: int = 1) -> List[str]: # Collapse runs of blank lines to at most `keep_max_blank_run`. @@ -625,6 +618,9 @@ def _normalize_for_diff(lines: List[str], keep_max_blank_run: int = 1) -> List[s label_old = f"a/{rel.as_posix()}" label_new = f"b/{rel.as_posix()}" + # Check if this is a .xircuits file + is_xircuits_file = rel.suffix.lower() == '.xircuits' + # Binary file summary if _is_binary_file(dst) or _is_binary_file(src): # Added @@ -640,6 +636,21 @@ def _normalize_for_diff(lines: List[str], keep_max_blank_run: int = 1) -> List[s old_lines = _normalize_for_diff(_read_text(dst)) new_lines = _normalize_for_diff(_read_text(src)) + # For .xircuits files, just show summary + if is_xircuits_file: + # Added + if dst is None or (dst and not dst.exists()): + return f".xircuits file added: {rel.as_posix()} ({len(new_lines)} lines)\n" + # Deleted + if src is None or (src and not src.exists()): + return f".xircuits file deleted: {rel.as_posix()} ({len(old_lines)} lines)\n" + # Updated - show line count change + if old_lines == new_lines: + return "" # No actual changes + line_diff = len(new_lines) - len(old_lines) + sign = "+" if line_diff > 0 else "" + return f".xircuits file updated: {rel.as_posix()} ({len(old_lines)} -> {len(new_lines)} lines, {sign}{line_diff})\n" + # Use lineterm="\n" so each diff line includes its newline -> headers won't glue diff_lines = list(difflib.unified_diff( old_lines, new_lines, @@ -699,6 +710,7 @@ def update_all_libraries( remote_only: bool = False, exclude: List[str] = None, respect_refs: bool = False, + no_overwrite: bool = False, ) -> dict: """ Update all installed component libraries found in xai_components/. @@ -711,6 +723,7 @@ def update_all_libraries( remote_only: Only update non-core libraries exclude: List of library names to skip respect_refs: Honor pinned refs in metadata (default: pull latest) + no_overwrite: Skip updating files with local modifications Returns: Dict with 'success', 'failed', 'skipped' lists and summary stats @@ -766,6 +779,7 @@ def update_all_libraries( prune=prune, install_deps=install_deps, use_latest=not respect_refs, + no_overwrite=no_overwrite, ) results["success"].append((lib_name, message)) diff --git a/xircuits/start_xircuits.py b/xircuits/start_xircuits.py index fee9e4bc..93a2e995 100644 --- a/xircuits/start_xircuits.py +++ b/xircuits/start_xircuits.py @@ -167,6 +167,7 @@ def cmd_update_library(args, extra_args=[]): remote_only=args.remote_only, exclude=exclude_list, respect_refs=args.respect_refs, + no_overwrite=args.no_overwrite, ) except Exception as e: @@ -185,7 +186,7 @@ def cmd_update_library(args, extra_args=[]): dry_run=args.dry_run, prune=args.prune, install_deps=args.install_deps, - # use_latest defaults to False for single library updates + no_overwrite=args.no_overwrite, # <-- ADD THIS ) print(message) @@ -329,6 +330,8 @@ def main(): help='Comma-separated list of libraries to exclude (e.g., gradio,opencv)') update_parser.add_argument('--respect-refs', action='store_true', help='Honor pinned refs in metadata (default: pull latest for --all)') + update_parser.add_argument('--no-overwrite', action='store_true', + help='Skip updating files with local modifications (preserve local changes)') update_parser.set_defaults(func=cmd_update_library) From f7257d2d050cd148d535c1998a5e6241241de626 Mon Sep 17 00:00:00 2001 From: MFA-X-AI Date: Wed, 8 Oct 2025 22:26:19 +0900 Subject: [PATCH 06/18] fix syntax errors --- xircuits/library/update_library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xircuits/library/update_library.py b/xircuits/library/update_library.py index 3904cd9b..18247170 100644 --- a/xircuits/library/update_library.py +++ b/xircuits/library/update_library.py @@ -349,7 +349,7 @@ def _sync_single_file(src_file: Path, dst_file: Path, dry_run: bool, timestamp: unchanged.append(rel_path) else: # File has local modifications - if no_overwrite: BLOCK + if no_overwrite: print(f"⊙ {rel_path} (local changes preserved)") unchanged.append(rel_path) elif dry_run: @@ -538,7 +538,7 @@ def _sync_with_backups( report.unchanged.append(str(rel)) else: # File differs - local modification detected - if no_overwrite: BLOCK + if no_overwrite: print(f"⊙ {rel} (local changes preserved)") report.unchanged.append(str(rel)) elif dry_run: From a687c90a844c71d95b6d8724072a0bbfc5026a36 Mon Sep 17 00:00:00 2001 From: MFA-X-AI Date: Thu, 9 Oct 2025 00:39:46 +0900 Subject: [PATCH 07/18] reuse existing functions --- xircuits/library/update_library.py | 235 +++++++++++++++++------------ xircuits/start_xircuits.py | 2 +- 2 files changed, 136 insertions(+), 101 deletions(-) diff --git a/xircuits/library/update_library.py b/xircuits/library/update_library.py index 18247170..41951286 100644 --- a/xircuits/library/update_library.py +++ b/xircuits/library/update_library.py @@ -8,13 +8,11 @@ from typing import List, Optional, Set from importlib_resources import files, as_file -from xircuits.utils.pathing import resolve_working_dir, components_base_dir -from .core_libs import is_core_library - from xircuits.utils.pathing import ( resolve_working_dir, resolve_library_dir, normalize_library_slug, + components_base_dir, ) from xircuits.utils.git_toml_manager import ( read_component_metadata_entry, @@ -30,6 +28,8 @@ from xircuits.utils.venv_ops import install_specs from xircuits.handlers.request_remote import get_remote_config +from .core_libs import is_core_library + @dataclass class SourceSpec: @@ -70,7 +70,7 @@ def update_library( dry_run: If True, compute actions and print a unified diff (no files changed). prune: If True, also archive local-only files/dirs (rename to *.bak). install_deps: If True (default), update per-library extra and install its requirements. - use_latest: If True, ignore metadata ref and pull latest from default branch. + use_latest: If True, ignore metadata ref and pull latest from default branch. no_overwrite: If True, skip updating files with local modifications. Returns: @@ -105,7 +105,6 @@ def update_library( # Regular (non-core) library update from git return _update_from_git( lib_name=lib_name, - working_dir=working_dir, repo=repo, ref=ref, dry_run=dry_run, @@ -160,7 +159,7 @@ def _update_from_wheel( # Sync files if is_base_file: - report = _sync_single_file(src_path, dst_path, dry_run, timestamp, no_overwrite) # <-- ADD no_overwrite + report = _sync_single_file(src_path, dst_path, dry_run, timestamp, no_overwrite) else: report = _sync_with_backups( source_root=src_path, @@ -205,7 +204,6 @@ def _update_from_wheel( def _update_from_git( lib_name: str, - working_dir: Path, repo: Optional[str], ref: Optional[str], dry_run: bool, @@ -218,6 +216,10 @@ def _update_from_git( Update a regular component library from its git repository. (Original update_library logic) """ + working_dir = resolve_working_dir() + if working_dir is None: + raise RuntimeError("Xircuits working directory not found. Run 'xircuits init' first.") + dest_dir = resolve_library_dir(lib_name) if not dest_dir.exists() or not dest_dir.is_dir(): raise FileNotFoundError( @@ -233,25 +235,22 @@ def _update_from_git( "Ensure it was installed (so metadata exists) or present in your index.json." ) - print(f"Updating '{lib_name}' from {source_spec.repo_url} @ {source_spec.desired_ref or 'default'}...") + print( + f"Updating {lib_name} from {source_spec.repo_url} " + f"{'(ref='+source_spec.desired_ref+')' if source_spec.desired_ref else '(default branch)'}" + ) temp_repo_dir = Path(tempfile.mkdtemp(prefix=f"update_{lib_name}_")) try: - clone_from_github_url( - library_url=source_spec.repo_url, - target_dir=str(temp_repo_dir), - ref=source_spec.desired_ref - ) + git_clone_shallow(source_spec.repo_url, temp_repo_dir) + if source_spec.desired_ref: + git_checkout_ref(temp_repo_dir, source_spec.desired_ref) - temp_lib_dir = temp_repo_dir / lib_name - if not temp_lib_dir.exists(): - raise FileNotFoundError( - f"No '{lib_name}' subdirectory in cloned repository. " - "Ensure the library name matches the folder in the repo." - ) + repo_url_final, resolved_ref, is_tag = get_git_metadata(str(temp_repo_dir)) + src_dir = _select_library_source_dir(temp_repo_dir, lib_name) - sync_report = _sync_with_backups( - source_root=temp_lib_dir, + report = _sync_with_backups( + source_root=src_dir, destination_root=dest_dir, dry_run=dry_run, prune=prune, @@ -260,14 +259,12 @@ def _update_from_git( ) # On DRY-RUN: show and save a unified diff of planned changes. - # This is an *in-memory* comparison; no filesystem writes occur, and we don't - # ever touch '/dev/null'. For adds/deletes we diff against an empty side. if dry_run: diff_text = _build_combined_diff( - temp_lib_dir, dest_dir, - added=sync_report.added, - updated=sync_report.updated, - deleted=sync_report.deleted + src_dir, dest_dir, + added=report.added, + updated=report.updated, + deleted=report.deleted ) if diff_text.strip(): diff_path = dest_dir / f"{lib_name}.update.{timestamp}.dry-run.diff.txt" @@ -278,21 +275,51 @@ def _update_from_git( # Update pyproject metadata / deps (skipped during dry-run) if not dry_run: - if repo: - write_component_metadata_entry(lib_name, repo, source_spec.desired_ref) + try: + record_component_metadata( + library_name=lib_name, + member_path=str(dest_dir), + repo_url=repo_url_final or source_spec.repo_url, + ref=resolved_ref or source_spec.desired_ref or "latest", + is_tag=is_tag, + ) + except Exception as e: + print(f"Warning: could not update pyproject metadata: {e}") + + # Requirements / extras install + try: + reqs = read_requirements_for_library(dest_dir) + except Exception as e: + reqs = [] + print(f"Warning: could not read requirements for {lib_name}: {e}") + + # Always refresh the per-library extra + meta extra on update + try: + set_library_extra(lib_name, reqs) + rebuild_meta_extra("xai-components") + except Exception as e: + print(f"Warning: could not update optional-dependencies for {lib_name}: {e}") if install_deps: - reqs_list = read_requirements_for_library(dest_dir) - if reqs_list: - print(f"Installing requirements for {lib_name}...") - install_per_library_extra(lib_name, reqs_list) - else: - print(f"No requirements.txt found for {lib_name}; skipping dependency install.") + try: + if reqs: + print(f"Installing Python dependencies for {lib_name}...") + install_specs(reqs) + print(f"✓ Dependencies for {lib_name} installed.") + else: + print(f"No requirements.txt entries for {lib_name}; nothing to install.") + except Exception as e: + print(f"Warning: installing dependencies for {lib_name} failed:{e}".rstrip()) + + try: + regenerate_lock_file() + except Exception as e: + print(f"Warning: could not regenerate lock file: {e}") summary = ( f"{lib_name} update " - f"(added: {len(sync_report.added)}, updated: {len(sync_report.updated)}, " - f"deleted: {len(sync_report.deleted)}, unchanged: {len(sync_report.unchanged)})" + f"(added: {len(report.added)}, updated: {len(report.updated)}, " + f"deleted: {len(report.deleted)}, unchanged: {len(report.unchanged)})" ) return summary finally: @@ -328,7 +355,13 @@ def _extract_base_py_from_wheel(dest_dir: Path) -> None: raise RuntimeError(f"Failed to extract base.py from wheel: {e}") -def _sync_single_file(src_file: Path, dst_file: Path, dry_run: bool, timestamp: str, no_overwrite: bool = False) -> SyncReport: +def _sync_single_file( + src_file: Path, + dst_file: Path, + dry_run: bool, + timestamp: str, + no_overwrite: bool = False +) -> SyncReport: """ Sync a single file with backup support. """ @@ -494,83 +527,85 @@ def _sync_with_backups( no_overwrite: bool = False, ) -> SyncReport: """ - Synchronize source_root into destination_root. - - - Added files/dirs are copied. - - Modified files: backed up then overwritten (unless no_overwrite=True). - - Local-only files: left alone unless prune=True. - - If no_overwrite=True, skip updating files that differ locally. - """ - report = SyncReport(added=[], updated=[], deleted=[], unchanged=[]) - - src_files = _gather_files_recursive(source_root) - dst_files = _gather_files_recursive(destination_root) + Perform the filesystem sync (or simulate it on dry_run). - src_rel = {p.relative_to(source_root) for p in src_files} - dst_rel = {p.relative_to(destination_root) for p in dst_files} + Prints concise markers: + +++ path + --- path (backup: ) for updated/deleted + --- path (would backup) for updated/deleted (dry-run mode) + ⊙ path (local changes preserved) when no_overwrite=True + """ + added: List[str] = [] + updated: List[str] = [] + deleted: List[str] = [] + unchanged: List[str] = [] - added_rel = src_rel - dst_rel - common_rel = src_rel & dst_rel - local_only = dst_rel - src_rel + source_files = _walk_files(source_root) + destination_files = _walk_files(destination_root) - # 1) Added files - for rel in sorted(added_rel): + # Add / update + for rel in sorted(source_files, key=str): src = source_root / rel dst = destination_root / rel - report.added.append(str(rel)) - print(f"+++ {rel}") - if not dry_run: - dst.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, dst) + path_str = rel.as_posix() - # 2) Common files - for rel in sorted(common_rel): - src = source_root / rel - dst = destination_root / rel if not dst.exists(): - report.added.append(str(rel)) - print(f"+++ {rel}") - if not dry_run: - dst.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, dst) - elif src.is_file() and dst.is_file(): - if _files_equal(src, dst): - report.unchanged.append(str(rel)) - else: - # File differs - local modification detected - if no_overwrite: - print(f"⊙ {rel} (local changes preserved)") - report.unchanged.append(str(rel)) - elif dry_run: - backup_name = _backup_in_place(dst, timestamp, dry_run) - report.updated.append(str(rel)) - print(f"--- {rel} (would backup as: {backup_name})") - print(f"+++ {rel}") - else: - backup_name = _backup_in_place(dst, timestamp, dry_run) - report.updated.append(str(rel)) - print(f"--- {rel} (backup: {backup_name})") - print(f"+++ {rel}") - shutil.copy2(src, dst) + print(f"+++ {path_str}") + _copy_file(src, dst, dry_run) + added.append(path_str) + continue + + if _files_equal(src, dst): + unchanged.append(path_str) + continue + + # File differs - check no_overwrite flag + if no_overwrite: + print(f"⊙ {path_str} (local changes preserved)") + unchanged.append(path_str) + continue + + if dry_run: + backup_name = _backup_in_place(dst, timestamp, dry_run) + print(f"--- {path_str} (would backup as: {backup_name})") + print(f"+++ {path_str}") else: - report.unchanged.append(str(rel)) + backup_name = _backup_in_place(dst, timestamp, dry_run) + print(f"--- {path_str} (backup: {backup_name})") + print(f"+++ {path_str}") + _copy_file(src, dst, dry_run) + updated.append(path_str) - # 3) Local-only files (prune if requested) + # Deletions (dest-only) — only when prune=True if prune: - for rel in sorted(local_only): + source_dirs = _walk_dirs(source_root) + destination_dirs = _walk_dirs(destination_root) + + for rel in sorted(destination_files - source_files, key=str): dst = destination_root / rel - if dst.exists(): + path_str = rel.as_posix() + if dry_run: + backup_name = _backup_in_place(dst, timestamp, dry_run) + print(f"--- {path_str} (would backup as: {backup_name})") + else: backup_name = _backup_in_place(dst, timestamp, dry_run) - report.deleted.append(str(rel)) + print(f"--- {path_str} (backup: {backup_name})") + deleted.append(path_str) + + # Directories only in destination — deepest first + for rel in sorted(destination_dirs - source_dirs, key=lambda p: len(p.as_posix()), reverse=True): + dst_dir = destination_root / rel + if dst_dir.exists(): + path_str = rel.as_posix() + "/" if dry_run: - print(f"--- {rel} (would prune as: {backup_name})") + backup_name = _backup_in_place(dst_dir, timestamp, dry_run) + print(f"--- {path_str} (would backup as: {backup_name})") else: - print(f"--- {rel} (pruned: {backup_name})") - else: - for rel in sorted(local_only): - report.unchanged.append(str(rel)) + backup_name = _backup_in_place(dst_dir, timestamp, dry_run) + print(f"--- {path_str} (backup: {backup_name})") + deleted.append(path_str) - return report + return SyncReport(added=added, updated=updated, deleted=deleted, unchanged=unchanged) # ---------- Diff helpers (for dry-run) ---------- diff --git a/xircuits/start_xircuits.py b/xircuits/start_xircuits.py index 93a2e995..974087da 100644 --- a/xircuits/start_xircuits.py +++ b/xircuits/start_xircuits.py @@ -186,7 +186,7 @@ def cmd_update_library(args, extra_args=[]): dry_run=args.dry_run, prune=args.prune, install_deps=args.install_deps, - no_overwrite=args.no_overwrite, # <-- ADD THIS + no_overwrite=args.no_overwrite, ) print(message) From 819a859a4632c71bd3ee1297140af2bdc9e33af6 Mon Sep 17 00:00:00 2001 From: rabea-Al Date: Thu, 9 Oct 2025 03:09:30 +0800 Subject: [PATCH 08/18] Add "View details" dialog for long notifications --- src/helpers/notificationAugmentor.ts | 74 ++++++++++++++++++++++++++++ src/index.tsx | 5 +- style/base.css | 12 +++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/helpers/notificationAugmentor.ts diff --git a/src/helpers/notificationAugmentor.ts b/src/helpers/notificationAugmentor.ts new file mode 100644 index 00000000..8b3e32fa --- /dev/null +++ b/src/helpers/notificationAugmentor.ts @@ -0,0 +1,74 @@ +import { Notification, showDialog, Dialog } from '@jupyterlab/apputils'; +import { Widget } from '@lumino/widgets'; + +const MAX_VISIBLE_CHARS = 140; +const VIEW_DETAILS_LABEL = 'View details'; + +function toPlainText(value: unknown): string { + try { + return typeof value === 'string' ? value : JSON.stringify(value, null, 2); + } catch { + return String(value ?? ''); + } +} + +function createViewDetailsAction(fullMessage: string, dialogTitle = 'Details'): Notification.IAction { + return { + label: VIEW_DETAILS_LABEL, + caption: 'Show full message', + callback: async () => { + const dialogBody = new Widget(); + dialogBody.addClass('xircuits-notification-details'); + + const pre = document.createElement('pre'); + pre.textContent = fullMessage; + dialogBody.node.appendChild(pre); + + const copyButton = Dialog.createButton({ label: 'Copy' }); + const closeButton = Dialog.okButton({ label: 'Close' }); + + const result = await showDialog({ + title: dialogTitle, + body: dialogBody, + buttons: [copyButton, closeButton] + }); + + if (result.button.label === 'Copy') { + await navigator.clipboard.writeText(fullMessage); + } + } + }; +} + +function ensureViewDetailsAction( + messageText: string, + options: any = {}, + title?: string +): any { + if (messageText.length <= MAX_VISIBLE_CHARS) return options; + const actions = [...(options.actions ?? [])]; + if (!actions.some((a: any) => a?.label === VIEW_DETAILS_LABEL)) { + actions.push(createViewDetailsAction(messageText, title)); + } + return { ...options, actions }; +} + +export function augmentNotifications(): void { + const NotificationObj: any = Notification as any; + if (NotificationObj.__xircuitsAugmented) return; + + const wrap = (method: 'error' | 'warning' | 'info' | 'success') => { + const original = NotificationObj[method]?.bind(Notification); + if (!original) return; + + NotificationObj[method] = (...args: any[]) => { + const [rawMessage, rawOptions] = args; + const text = toPlainText(rawMessage); + const options = ensureViewDetailsAction(text, rawOptions); + return original(text, options); + }; + }; + + ['error', 'warning', 'info', 'success'].forEach(wrap); + NotificationObj.__xircuitsAugmented = true; +} diff --git a/src/index.tsx b/src/index.tsx index 1853ae69..0a208081 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -34,7 +34,7 @@ import { commandIDs } from "./commands/CommandIDs"; import { IEditorTracker } from '@jupyterlab/fileeditor'; import { IMainMenu } from '@jupyterlab/mainmenu'; import { handleInstall } from './context-menu/TrayContextMenu'; - +import { augmentNotifications } from './helpers/notificationAugmentor'; import { installComponentPreview } from './component_info_sidebar/previewHelper'; const FACTORY = 'Xircuits editor'; @@ -80,6 +80,9 @@ const xircuits: JupyterFrontEndPlugin = { console.log('Xircuits is activated!'); + // Add "View details" to long notifications + augmentNotifications(); + // Creating the widget factory to register it so the document manager knows about // our new DocumentWidget const widgetFactory = new XircuitsFactory({ diff --git a/style/base.css b/style/base.css index 4642ce49..d6f1dc90 100644 --- a/style/base.css +++ b/style/base.css @@ -158,6 +158,18 @@ body.light-mode jp-button[title="Toggle Light/Dark Mode"] .moon { visibility: body.light-mode jp-button[title="Toggle Light/Dark Mode"] .sun { visibility: visible; } +.xircuits-notification-details { + max-height: 60vh; + overflow: auto; + padding: 0.25rem; +} +.xircuits-notification-details pre { + white-space: pre-wrap; + word-break: break-word; + margin: 0; + font-family: var(--jp-code-font-family); + font-size: var(--jp-code-font-size); +} From c207cb77d839b21daa651ee2aae593ca5f2f4521 Mon Sep 17 00:00:00 2001 From: rabea-al Date: Thu, 9 Oct 2025 16:28:36 +0800 Subject: [PATCH 09/18] clean up library ID returned by resolveLibraryForNode --- src/helpers/notificationEffects.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/helpers/notificationEffects.ts b/src/helpers/notificationEffects.ts index e3592f95..491d29c3 100644 --- a/src/helpers/notificationEffects.ts +++ b/src/helpers/notificationEffects.ts @@ -92,9 +92,11 @@ export async function resolveLibraryForNode( const candidateId = pathToLibraryId(extras.path); if (!candidateId) return { libId: null, status: 'unknown' }; + const cleanLibId = normalizeLibraryName(candidateId.replace(/^xai_components[\/\\]/i, '')); + const idx = await loadLibraryIndex(); - const entry = idx.get(candidateId); - return computeStatusFromEntry(entry, candidateId); + const entry = idx.get(cleanLibId); + return computeStatusFromEntry(entry, cleanLibId); } export async function showInstallForRemoteLibrary(args: { From 9a353a4a9b7c5c8f941aec01ed0cf6c339f252b9 Mon Sep 17 00:00:00 2001 From: rabea-Al Date: Sun, 12 Oct 2025 17:51:04 +0800 Subject: [PATCH 10/18] Improve inline copy button with temporary "Copied" hint in details dialog --- src/helpers/notificationAugmentor.ts | 49 +++++++++++++++++++++++----- src/helpers/notificationEffects.ts | 4 +-- style/base.css | 30 +++++++++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/src/helpers/notificationAugmentor.ts b/src/helpers/notificationAugmentor.ts index 8b3e32fa..38fe129e 100644 --- a/src/helpers/notificationAugmentor.ts +++ b/src/helpers/notificationAugmentor.ts @@ -1,5 +1,6 @@ import { Notification, showDialog, Dialog } from '@jupyterlab/apputils'; import { Widget } from '@lumino/widgets'; +import { copyIcon } from '@jupyterlab/ui-components'; const MAX_VISIBLE_CHARS = 140; const VIEW_DETAILS_LABEL = 'View details'; @@ -17,25 +18,55 @@ function createViewDetailsAction(fullMessage: string, dialogTitle = 'Details'): label: VIEW_DETAILS_LABEL, caption: 'Show full message', callback: async () => { + const DURATION = 1200; + const dialogBody = new Widget(); dialogBody.addClass('xircuits-notification-details'); + const wrap = document.createElement('div'); + wrap.className = 'x-details-copyWrap'; + + const copyBtn = document.createElement('button'); + copyBtn.className = 'x-copy-icon-btn jp-Button jp-mod-minimal'; + copyBtn.type = 'button'; + copyBtn.title = 'Copy'; + copyBtn.setAttribute('aria-label', 'Copy'); + copyIcon.element({ container: copyBtn, height: '16px', width: '16px' }); + + wrap.appendChild(copyBtn); + const pre = document.createElement('pre'); + pre.className = 'x-details-pre'; pre.textContent = fullMessage; - dialogBody.node.appendChild(pre); - const copyButton = Dialog.createButton({ label: 'Copy' }); - const closeButton = Dialog.okButton({ label: 'Close' }); + dialogBody.node.append(wrap, pre); + + let timer: number | null = null; + copyBtn.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(fullMessage); - const result = await showDialog({ + copyBtn.classList.add('is-copied'); + copyBtn.title = 'Copied'; + copyBtn.setAttribute('aria-label', 'Copied'); + + if (timer) clearTimeout(timer); + timer = window.setTimeout(() => { + copyBtn.classList.remove('is-copied'); + copyBtn.title = 'Copy'; + copyBtn.setAttribute('aria-label', 'Copy'); + timer = null; + }, DURATION); + } catch (err) { + console.error('Copy failed', err); + } + }); + + await showDialog({ title: dialogTitle, body: dialogBody, - buttons: [copyButton, closeButton] + buttons: [Dialog.okButton({ label: 'Close' })] }); - - if (result.button.label === 'Copy') { - await navigator.clipboard.writeText(fullMessage); - } } }; } diff --git a/src/helpers/notificationEffects.ts b/src/helpers/notificationEffects.ts index 99faca72..e96a623f 100644 --- a/src/helpers/notificationEffects.ts +++ b/src/helpers/notificationEffects.ts @@ -92,10 +92,10 @@ export async function resolveLibraryForNode( const candidateId = pathToLibraryId(extras.path); if (!candidateId) return { libId: null, status: 'unknown' }; - const cleanLibId = normalizeLibraryName(candidateId.replace(/^xai_components[\/\\]/i, '')); + const cleanLibId = candidateId.replace(/^xai_components[\/\\]/i, ''); const idx = await loadLibraryIndex(); - const entry = idx.get(cleanLibId); + const entry = idx.get(candidateId); return computeStatusFromEntry(entry, cleanLibId); } diff --git a/style/base.css b/style/base.css index d6f1dc90..338280db 100644 --- a/style/base.css +++ b/style/base.css @@ -172,4 +172,34 @@ body.light-mode jp-button[title="Toggle Light/Dark Mode"] .sun { visibility: v font-size: var(--jp-code-font-size); } +.xircuits-notification-details { position: relative; } + +.x-details-copyWrap { + position: absolute; + top: 6px; + right: 6px; +} + +.x-copy-icon-btn { position: relative; } + +.x-copy-icon-btn.is-copied::after { + content: "Copied"; + position: absolute; + top: 50%; + right: calc(100% + 8px); + transform: translateY(-50%); + white-space: nowrap; + pointer-events: none; + padding: 2px 6px; + background: var(--jp-layout-color2); + border: 1px solid var(--jp-border-color2); + border-radius: 4px; + font-size: var(--jp-ui-font-size1); + line-height: 1.4; + z-index: 1; +} + +.x-details-pre { margin-top: 28px; } + + From 9d15615e2ae6e147cce835be13036e1bc4cc1a98 Mon Sep 17 00:00:00 2001 From: rabea-Al Date: Mon, 13 Oct 2025 18:32:06 +0800 Subject: [PATCH 11/18] Fix copy button overlap --- style/base.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/style/base.css b/style/base.css index 338280db..45f55e7e 100644 --- a/style/base.css +++ b/style/base.css @@ -199,7 +199,9 @@ body.light-mode jp-button[title="Toggle Light/Dark Mode"] .sun { visibility: v z-index: 1; } -.x-details-pre { margin-top: 28px; } +.xircuits-notification-details .x-details-pre { + margin-top: 36px; +} From 41bc12f31535d116931fcbd16b5cf1981face11f Mon Sep 17 00:00:00 2001 From: rabea-Al Date: Tue, 14 Oct 2025 00:42:57 +0800 Subject: [PATCH 12/18] Revert "Improve inline copy button with temporary "Copied" hint in details dialog" This reverts commit 9a353a4a9b7c5c8f941aec01ed0cf6c339f252b9. --- src/helpers/notificationAugmentor.ts | 49 +++++----------------------- style/base.css | 36 +------------------- 2 files changed, 10 insertions(+), 75 deletions(-) diff --git a/src/helpers/notificationAugmentor.ts b/src/helpers/notificationAugmentor.ts index 38fe129e..8b3e32fa 100644 --- a/src/helpers/notificationAugmentor.ts +++ b/src/helpers/notificationAugmentor.ts @@ -1,6 +1,5 @@ import { Notification, showDialog, Dialog } from '@jupyterlab/apputils'; import { Widget } from '@lumino/widgets'; -import { copyIcon } from '@jupyterlab/ui-components'; const MAX_VISIBLE_CHARS = 140; const VIEW_DETAILS_LABEL = 'View details'; @@ -18,55 +17,25 @@ function createViewDetailsAction(fullMessage: string, dialogTitle = 'Details'): label: VIEW_DETAILS_LABEL, caption: 'Show full message', callback: async () => { - const DURATION = 1200; - const dialogBody = new Widget(); dialogBody.addClass('xircuits-notification-details'); - const wrap = document.createElement('div'); - wrap.className = 'x-details-copyWrap'; - - const copyBtn = document.createElement('button'); - copyBtn.className = 'x-copy-icon-btn jp-Button jp-mod-minimal'; - copyBtn.type = 'button'; - copyBtn.title = 'Copy'; - copyBtn.setAttribute('aria-label', 'Copy'); - copyIcon.element({ container: copyBtn, height: '16px', width: '16px' }); - - wrap.appendChild(copyBtn); - const pre = document.createElement('pre'); - pre.className = 'x-details-pre'; pre.textContent = fullMessage; + dialogBody.node.appendChild(pre); - dialogBody.node.append(wrap, pre); - - let timer: number | null = null; - copyBtn.addEventListener('click', async () => { - try { - await navigator.clipboard.writeText(fullMessage); + const copyButton = Dialog.createButton({ label: 'Copy' }); + const closeButton = Dialog.okButton({ label: 'Close' }); - copyBtn.classList.add('is-copied'); - copyBtn.title = 'Copied'; - copyBtn.setAttribute('aria-label', 'Copied'); - - if (timer) clearTimeout(timer); - timer = window.setTimeout(() => { - copyBtn.classList.remove('is-copied'); - copyBtn.title = 'Copy'; - copyBtn.setAttribute('aria-label', 'Copy'); - timer = null; - }, DURATION); - } catch (err) { - console.error('Copy failed', err); - } - }); - - await showDialog({ + const result = await showDialog({ title: dialogTitle, body: dialogBody, - buttons: [Dialog.okButton({ label: 'Close' })] + buttons: [copyButton, closeButton] }); + + if (result.button.label === 'Copy') { + await navigator.clipboard.writeText(fullMessage); + } } }; } diff --git a/style/base.css b/style/base.css index 45f55e7e..8bdd090b 100644 --- a/style/base.css +++ b/style/base.css @@ -170,38 +170,4 @@ body.light-mode jp-button[title="Toggle Light/Dark Mode"] .sun { visibility: v margin: 0; font-family: var(--jp-code-font-family); font-size: var(--jp-code-font-size); -} - -.xircuits-notification-details { position: relative; } - -.x-details-copyWrap { - position: absolute; - top: 6px; - right: 6px; -} - -.x-copy-icon-btn { position: relative; } - -.x-copy-icon-btn.is-copied::after { - content: "Copied"; - position: absolute; - top: 50%; - right: calc(100% + 8px); - transform: translateY(-50%); - white-space: nowrap; - pointer-events: none; - padding: 2px 6px; - background: var(--jp-layout-color2); - border: 1px solid var(--jp-border-color2); - border-radius: 4px; - font-size: var(--jp-ui-font-size1); - line-height: 1.4; - z-index: 1; -} - -.xircuits-notification-details .x-details-pre { - margin-top: 36px; -} - - - +} \ No newline at end of file From 136a9d4b242362a65d49649eee07acc46349fb4c Mon Sep 17 00:00:00 2001 From: rabea-Al Date: Tue, 14 Oct 2025 01:48:34 +0800 Subject: [PATCH 13/18] Make canvas updates fire only after real link changes, not every small drag --- .../ComponentPreviewWidget.tsx | 6 ---- src/components/XircuitsBodyWidget.tsx | 28 +++++++++++++++++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/component_info_sidebar/ComponentPreviewWidget.tsx b/src/component_info_sidebar/ComponentPreviewWidget.tsx index dd8c3f1c..d4aee0bd 100644 --- a/src/component_info_sidebar/ComponentPreviewWidget.tsx +++ b/src/component_info_sidebar/ComponentPreviewWidget.tsx @@ -373,12 +373,6 @@ export class ComponentPreviewWidget extends SidePanel { const currentNode = this._model?.node; if (!engine || !currentNode) return; - // Skip updating sidebar if a link is still being dragged (incomplete connection) - const hasUnfinishedLink = Object.values(engine.getModel()?.getLinks?.() ?? {}).some( - (link: any) => !link.getTargetPort?.() - ); - if (hasUnfinishedLink) return; - // Refresh node reference in case the model recreated it after a change const id = currentNode.getID?.(); const latestNode = engine.getModel?.().getNodes?.().find(n => n.getID?.() === id); diff --git a/src/components/XircuitsBodyWidget.tsx b/src/components/XircuitsBodyWidget.tsx index b6e1e5d1..5fecb6be 100644 --- a/src/components/XircuitsBodyWidget.tsx +++ b/src/components/XircuitsBodyWidget.tsx @@ -440,12 +440,37 @@ export const BodyWidget: FC = ({ setSaved(false); } }, []); + + // Schedule a single canvas update per frame and ignore incomplete link drags + const scheduleCanvasEmit = React.useMemo(() => { + let scheduled = false; + return () => { + if (scheduled) return; + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + + const model = xircuitsApp.getDiagramEngine().getModel(); + + // skip if a link is still being dragged (no target port yet) + const draggingUnfinished = + !!model && + Object.values(model.getLinks?.() ?? {}).some( + (l: any) => !(l?.getTargetPort?.()) + ); + if (!draggingUnfinished) { + canvasUpdatedSignal.emit({ reason: 'content' }); + } + }); + }; + }, [xircuitsApp]); const onChange = useCallback((): void => { if (skipSerializationRef.current) { return; } serializeModel(); + scheduleCanvasEmit(); }, [serializeModel]); @@ -475,7 +500,7 @@ export const BodyWidget: FC = ({ return () => clearTimeout(timeout); }, linksUpdated: (event) => { - canvasUpdatedSignal.emit({ reason: 'content' }); + scheduleCanvasEmit(); const timeout = setTimeout(() => { event.link.registerListener({ sourcePortChanged: () => { @@ -499,7 +524,6 @@ export const BodyWidget: FC = ({ xircuitsApp.getDiagramEngine().setModel(deserializedModel); clearSearchFlags(); - canvasUpdatedSignal.emit({ reason: 'content' }); // On the first load, clear undo history and register global engine listeners if (initialRender.current) { From 94671ad8a136f22988b75d7583a875ce8f7ec48f Mon Sep 17 00:00:00 2001 From: rabea-Al Date: Wed, 15 Oct 2025 01:14:31 +0800 Subject: [PATCH 14/18] Add CLI update tests (PR #450) --- tests/cli_tests.py | 273 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) diff --git a/tests/cli_tests.py b/tests/cli_tests.py index 3a01bcc8..8727a0f7 100644 --- a/tests/cli_tests.py +++ b/tests/cli_tests.py @@ -728,3 +728,276 @@ def test_33_terminal_run_import_check(): assert "Compiled" in output, "Expected 'Compiled' not found in output." assert "Finished Executing" in output, "Expected 'Finished Executing' not found in output." assert Path("sqlite_sample.py").exists(), "Expected compiled .py file not found." + +def test_34_update_basic(tmp_path): + """ + Update end-to-end: + 1) init + install flask + 2) append a local marker to flask_components.py + 3) 'xircuits update flask' should create timestamped .bak and remove marker + 4) second update is idempotent (no new .bak) + """ + os.chdir(tmp_path) + + # init + install + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + install_out = (stdout + stderr).lower() + assert "library flask ready to use." in install_out + + lib_dir = Path("xai_components") / "xai_flask" + target_file = lib_dir / "flask_components.py" + assert target_file.exists(), f"Missing: {target_file}" + + # local change + marker = "# LOCAL_CHANGE_MARKER_FOR_UPDATE_TEST\n" + with open(target_file, "a", encoding="utf-8", errors="ignore") as f: + f.write(marker) + with open(target_file, "r", encoding="utf-8", errors="ignore") as f: + assert marker in f.read(), "Marker inject failed." + + # first update should back up and restore + stdout, stderr, rc = run_command("xircuits update flask", timeout=300) + out1 = (stdout or "") + (stderr or "") + assert "xai_flask update (" in out1, "Missing update summary on first run." + + bak_suffix = r"\.\d{8}-\d{6}\.bak$" + backup_re = re.compile(r"^flask_components\.py" + bak_suffix) + backups_after_first = {p.name for p in target_file.parent.iterdir() + if p.is_file() and backup_re.match(p.name)} + assert backups_after_first, "No timestamped backup created on first update." + + with open(target_file, "r", encoding="utf-8", errors="ignore") as f: + assert marker not in f.read(), "Marker still present after update; file not restored." + + # dependencies installed in default mode + assert "dependencies for xai_flask installed." in out1.lower() + + # second update should not create new .bak + stdout, stderr, rc = run_command("xircuits update flask", timeout=300) + out2 = (stdout or "") + (stderr or "") + assert "xai_flask update (" in out2, "Missing update summary on second run." + + backups_after_second = {p.name for p in target_file.parent.iterdir() + if p.is_file() and backup_re.match(p.name)} + assert backups_after_second == backups_after_first, \ + "Second update created additional backups; should be idempotent." + + +def test_35_update_dry_run(tmp_path): + """ + Dry-run Update: + - modify file + - run 'xircuits update flask --dry-run' + - no NEW .bak created + - marker remains + - diff file mentioned; no deps install + """ + os.chdir(tmp_path) + + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + + lib_dir = Path("xai_components") / "xai_flask" + target_file = lib_dir / "flask_components.py" + assert target_file.exists() + + backup_re = re.compile(r"^flask_components\.py\.\d{8}-\d{6}\.bak$") + pre_backups = { + p.name for p in target_file.parent.iterdir() + if p.is_file() and backup_re.match(p.name) + } + + marker = "# LOCAL_CHANGE_MARKER_FOR_UPDATE_TEST_DRYRUN\n" + with open(target_file, "a", encoding="utf-8", errors="ignore") as f: + f.write(marker) + + stdout, stderr, rc = run_command("xircuits update flask --dry-run", timeout=180) + output = (stdout or "") + (stderr or "") + assert rc == 0, f"Dry-run failed.\n{output}" + + # parse "would backup as: " + m = re.search(r"would backup as:\s*(flask_components\.py\.\d{8}-\d{6}\.bak)", output) + assert m, f"Couldn't parse 'would backup as' from output.\n{output}" + would_backup_name = m.group(1) + + post_backups = { + p.name for p in target_file.parent.iterdir() + if p.is_file() and backup_re.match(p.name) + } + + assert would_backup_name not in post_backups, \ + f"Dry-run created backup unexpectedly: {would_backup_name}" + assert post_backups == pre_backups, \ + f"Dry-run should not create backups.\nBefore: {sorted(pre_backups)}\nAfter: {sorted(post_backups)}" + + with open(target_file, "r", encoding="utf-8", errors="ignore") as f: + assert marker in f.read(), "Marker should remain after dry-run." + + unexpected_dep = ["Installing Python dependencies", "Dependencies for xai_flask installed"] + assert not any(h.lower() in output.lower() for h in unexpected_dep), \ + "Dry-run should not install deps." + assert "dry-run.diff.txt" in output.lower(), \ + "Expected dry-run diff mention in output." + + + +def test_36_update_specific_ref(tmp_path): + """ + Specific ref to v1.17.0: + - '(ref=v1.17.0)' appears in output + - requirements.txt contains 'flask-cors==4.0.0' + """ + os.chdir(tmp_path) + + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + + lib_dir = Path("xai_components") / "xai_flask" + assert lib_dir.exists() + reqs = lib_dir / "requirements.txt" + assert reqs.exists() + + stdout, stderr, rc = run_command("xircuits update flask --ref v1.17.0", timeout=300) + output = (stdout or "") + (stderr or "") + assert rc == 0, f"Update with ref failed.\n{output}" + + req_text = reqs.read_text(encoding="utf-8", errors="ignore").lower() + assert ("(ref=v1.17.0)" in output.replace(" ", "")) and ("flask-cors==4.0.0" in req_text), \ + ( + "Strict ref update check failed:\n" + "- Expected '(ref=v1.17.0)' in CLI output AND 'flask-cors==4.0.0' in requirements.txt\n" + f"--- CLI output ---\n{output}\n" + f"--- requirements.txt ---\n{req_text}" + ) + + +def test_37_update_prune_archives_locals(tmp_path): + """ + Prune: + - add local-only file/dir + - 'xircuits update flask --prune' archives with timestamped .bak and removes originals + - subsequent dry-run must not suggest backups for these local names + """ + os.chdir(tmp_path) + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + + lib_dir = Path("xai_components") / "xai_flask" + assert lib_dir.exists() + + local_file = lib_dir / "LOCAL_ONLY.md" + local_dir = lib_dir / "local_extra" / "subdir" + local_dir.mkdir(parents=True, exist_ok=True) + local_file.write_text("local-only note", encoding="utf-8") + (local_dir / "keep.txt").write_text("keep me", encoding="utf-8") + + assert local_file.exists() + assert local_dir.exists() + + stdout, stderr, rc = run_command("xircuits update flask --prune", timeout=300) + output = (stdout or "") + (stderr or "") + assert "xai_flask update (" in output + + bak_suffix = r"\.\d{8}-\d{6}\.bak$" + + # file archived + file_bak_exists = any( + re.match(r"LOCAL_ONLY\.md" + bak_suffix, p.name) for p in lib_dir.iterdir() if p.is_file() + ) + assert file_bak_exists, "Expected a timestamped .bak for LOCAL_ONLY.md" + assert not local_file.exists(), "Original LOCAL_ONLY.md should be removed" + + # dir archived + dir_bak_exists = any( + (p.is_dir() and re.match(r"local_extra" + bak_suffix, p.name)) + for p in lib_dir.iterdir() + ) + assert dir_bak_exists, "Expected a timestamped .bak directory for local_extra/" + assert not (lib_dir / "local_extra").exists(), "Original local_extra/ should be removed" + + stdout, stderr, rc = run_command("xircuits update flask --dry-run", timeout=180) + dry = (stdout or "") + (stderr or "") + assert rc == 0, f"dry-run after prune failed:\n{dry}" + + for forbidden in ["LOCAL_ONLY.md", "local_extra/"]: + assert forbidden not in dry, f"Dry-run suggests local artifacts still present: {forbidden}" + + +def test_38_update_without_installing_deps(tmp_path): + """ + --install-deps=false: + - no pip output + - files updated (timestamped .bak) and marker removed + """ + os.chdir(tmp_path) + + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + + lib = Path("xai_components") / "xai_flask" + tgt = lib / "flask_components.py" + assert lib.exists() and tgt.exists() + + marker = "# LOCAL_MARKER_FOR_INSTALL_DEPS_FALSE\n" + with tgt.open("a", encoding="utf-8", errors="ignore") as w: + w.write(marker) + + stdout, stderr, rc = run_command("xircuits update flask --install-deps=false", timeout=300) + out = (stdout or "") + (stderr or "") + assert rc == 0, f"update --install-deps=false failed:\n{out}" + + forbidden = [ + "Installing Python dependencies", + "Installing collected packages", + "Requirement already satisfied", + "Successfully installed", + "Uninstalling", + ] + assert not any(s in out for s in forbidden), ( + "Expected NO pip activity with --install-deps=false, but found:\n" + + "\n".join(s for s in forbidden if s in out) + ) + + bak_suffix = r"\.\d{8}-\d{6}\.bak$" + bak_re = re.compile(r"^flask_components\.py" + bak_suffix) + baks = {p.name for p in lib.iterdir() if p.is_file() and bak_re.match(p.name)} + assert baks, "Expected timestamped .bak (files updated) but none found." + + now = tgt.read_text(encoding="utf-8", errors="ignore") + assert marker not in now, "Local marker still present; backup/restore failed." + + +def test_39_update_repo_override_with_ref(tmp_path): + """ + --repo + --ref: + - update from an explicit repo/ref should create .bak and remove local marker + """ + import re + from pathlib import Path + import os + + os.chdir(tmp_path) + + run_command("xircuits init", timeout=30) + run_command("xircuits install flask", timeout=180) + + lib = Path("xai_components") / "xai_flask" + tgt = lib / "flask_components.py" + assert tgt.exists() + + marker = "# LOCAL_MARKER_FOR_REPO_OVERRIDE\n" + tgt.write_text(tgt.read_text(encoding="utf-8", errors="ignore") + marker, encoding="utf-8") + + repo = "https://github.com/XpressAI/xai-flask" + cmd = f"xircuits update flask --repo {repo} --ref main" + stdout, stderr, rc = run_command(cmd, timeout=300) + out = (stdout or "") + (stderr or "") + assert rc == 0, f"--repo update failed:\n{out}" + + bak_suffix = r"\.\d{8}-\d{6}\.bak$" + bak_re = re.compile(r"^flask_components\.py" + bak_suffix) + baks = {p.name for p in lib.iterdir() if p.is_file() and bak_re.match(p.name)} + assert baks, "Expected .bak after repo override" + + assert marker not in tgt.read_text(encoding="utf-8", errors="ignore") From 543aa683a1c2993e8969be28a65b50852d6c1093 Mon Sep 17 00:00:00 2001 From: rabea-Al Date: Wed, 15 Oct 2025 03:04:06 +0800 Subject: [PATCH 15/18] over core libs no-overwrite + preserve locals w/o prune --- tests/cli_tests.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/cli_tests.py b/tests/cli_tests.py index 8727a0f7..dc8551ca 100644 --- a/tests/cli_tests.py +++ b/tests/cli_tests.py @@ -1001,3 +1001,92 @@ def test_39_update_repo_override_with_ref(tmp_path): assert baks, "Expected .bak after repo override" assert marker not in tgt.read_text(encoding="utf-8", errors="ignore") + +def test_40_core_update_no_overwrite(tmp_path): + """ + Core libs: 'xircuits update ' fails (non-cloneable /tree URL), + no backups created, and local edits remain intact. + """ + os.chdir(tmp_path) + + # init + stdout, stderr, rc = run_command("xircuits init", timeout=30) + + # use xai_utils as a core example + lib_dir = Path("xai_components") / "xai_utils" + target = lib_dir / "utils.py" + assert target.exists(), f"Missing core file: {target}" + + # add a local marker + marker = "# CORE_UPDATE_MARKER\n" + with target.open("a", encoding="utf-8", errors="ignore") as w: + w.write(marker) + + # snapshot existing .bak (should stay unchanged) + bak_re = re.compile(r"^utils\.py\.\d{8}-\d{6}\.bak$") + pre_baks = {p.name for p in lib_dir.iterdir() if p.is_file() and bak_re.match(p.name)} + + # run update (expected to FAIL for core libs) + stdout, stderr, rc = run_command("xircuits update utils", timeout=120) + out = (stdout or "") + (stderr or "") + + # expect non-zero exit and the tree-url failure hint + assert rc != 0, "Core update should fail with current /tree URL behavior." + assert ("tree/master" in out) or ("not found" in out.lower()) or ("returned non-zero exit status" in out.lower()), \ + f"Unexpected failure output:\n{out}" + + # no new backups created + post_baks = {p.name for p in lib_dir.iterdir() if p.is_file() and bak_re.match(p.name)} + assert post_baks == pre_baks, "Failure must not create .bak files for core libs." + + # local marker still there (no overwrite happened) + now = target.read_text(encoding="utf-8", errors="ignore") + assert marker in now, "Core file was modified unexpectedly on failed update." + +def test_41_update_preserves_local_artifacts_without_prune(tmp_path): + """ + Update (no --prune): + - local-only file/dir should remain (no .bak, no removal) + - tracked file with marker gets backed up & restored + """ + os.chdir(tmp_path) + + # init + install remote lib + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + + lib_dir = Path("xai_components") / "xai_flask" + target = lib_dir / "flask_components.py" + assert target.exists() + + # local-only artifacts + local_file = lib_dir / "LOCAL_ONLY.md" + local_dir = lib_dir / "local_extra" + local_file.write_text("local note", encoding="utf-8") + (local_dir / "keep.txt").parent.mkdir(parents=True, exist_ok=True) + (local_dir / "keep.txt").write_text("keep me", encoding="utf-8") + + # marker on a tracked file to force update path + marker = "# LOCAL_MARKER_NO_PRUNE\n" + with target.open("a", encoding="utf-8", errors="ignore") as w: + w.write(marker) + + # run normal update (NO --prune) + stdout, stderr, rc = run_command("xircuits update flask", timeout=300) + out = (stdout or "") + (stderr or "") + assert "xai_flask update (" in out + + # tracked file: expect timestamped .bak and marker removed + bak_re = re.compile(r"^flask_components\.py\.\d{8}-\d{6}\.bak$") + baks = {p.name for p in lib_dir.iterdir() if p.is_file() and bak_re.match(p.name)} + assert baks, "Expected .bak for tracked file" + assert marker not in target.read_text(encoding="utf-8", errors="ignore") + + # local-only artifacts: must still exist; no .bak created for them + assert local_file.exists(), "LOCAL_ONLY.md should remain without --prune" + assert local_dir.exists(), "local_extra/ should remain without --prune" + ts_suffix = r"\.\d{8}-\d{6}\.bak$" + assert not any(re.match(r"LOCAL_ONLY\.md" + ts_suffix, p.name) for p in lib_dir.iterdir()), \ + "Unexpected .bak for LOCAL_ONLY.md without --prune" + assert not any(p.is_dir() and re.match(r"local_extra" + ts_suffix, p.name) for p in lib_dir.iterdir()), \ + "Unexpected .bak dir for local_extra/ without --prune" From a891bb9867a0cb59befe51cbdcca890a38435458 Mon Sep 17 00:00:00 2001 From: rabea-Al Date: Wed, 15 Oct 2025 01:14:31 +0800 Subject: [PATCH 16/18] Add CLI update tests (PR #450) --- tests/cli_tests.py | 273 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) diff --git a/tests/cli_tests.py b/tests/cli_tests.py index 3a01bcc8..8727a0f7 100644 --- a/tests/cli_tests.py +++ b/tests/cli_tests.py @@ -728,3 +728,276 @@ def test_33_terminal_run_import_check(): assert "Compiled" in output, "Expected 'Compiled' not found in output." assert "Finished Executing" in output, "Expected 'Finished Executing' not found in output." assert Path("sqlite_sample.py").exists(), "Expected compiled .py file not found." + +def test_34_update_basic(tmp_path): + """ + Update end-to-end: + 1) init + install flask + 2) append a local marker to flask_components.py + 3) 'xircuits update flask' should create timestamped .bak and remove marker + 4) second update is idempotent (no new .bak) + """ + os.chdir(tmp_path) + + # init + install + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + install_out = (stdout + stderr).lower() + assert "library flask ready to use." in install_out + + lib_dir = Path("xai_components") / "xai_flask" + target_file = lib_dir / "flask_components.py" + assert target_file.exists(), f"Missing: {target_file}" + + # local change + marker = "# LOCAL_CHANGE_MARKER_FOR_UPDATE_TEST\n" + with open(target_file, "a", encoding="utf-8", errors="ignore") as f: + f.write(marker) + with open(target_file, "r", encoding="utf-8", errors="ignore") as f: + assert marker in f.read(), "Marker inject failed." + + # first update should back up and restore + stdout, stderr, rc = run_command("xircuits update flask", timeout=300) + out1 = (stdout or "") + (stderr or "") + assert "xai_flask update (" in out1, "Missing update summary on first run." + + bak_suffix = r"\.\d{8}-\d{6}\.bak$" + backup_re = re.compile(r"^flask_components\.py" + bak_suffix) + backups_after_first = {p.name for p in target_file.parent.iterdir() + if p.is_file() and backup_re.match(p.name)} + assert backups_after_first, "No timestamped backup created on first update." + + with open(target_file, "r", encoding="utf-8", errors="ignore") as f: + assert marker not in f.read(), "Marker still present after update; file not restored." + + # dependencies installed in default mode + assert "dependencies for xai_flask installed." in out1.lower() + + # second update should not create new .bak + stdout, stderr, rc = run_command("xircuits update flask", timeout=300) + out2 = (stdout or "") + (stderr or "") + assert "xai_flask update (" in out2, "Missing update summary on second run." + + backups_after_second = {p.name for p in target_file.parent.iterdir() + if p.is_file() and backup_re.match(p.name)} + assert backups_after_second == backups_after_first, \ + "Second update created additional backups; should be idempotent." + + +def test_35_update_dry_run(tmp_path): + """ + Dry-run Update: + - modify file + - run 'xircuits update flask --dry-run' + - no NEW .bak created + - marker remains + - diff file mentioned; no deps install + """ + os.chdir(tmp_path) + + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + + lib_dir = Path("xai_components") / "xai_flask" + target_file = lib_dir / "flask_components.py" + assert target_file.exists() + + backup_re = re.compile(r"^flask_components\.py\.\d{8}-\d{6}\.bak$") + pre_backups = { + p.name for p in target_file.parent.iterdir() + if p.is_file() and backup_re.match(p.name) + } + + marker = "# LOCAL_CHANGE_MARKER_FOR_UPDATE_TEST_DRYRUN\n" + with open(target_file, "a", encoding="utf-8", errors="ignore") as f: + f.write(marker) + + stdout, stderr, rc = run_command("xircuits update flask --dry-run", timeout=180) + output = (stdout or "") + (stderr or "") + assert rc == 0, f"Dry-run failed.\n{output}" + + # parse "would backup as: " + m = re.search(r"would backup as:\s*(flask_components\.py\.\d{8}-\d{6}\.bak)", output) + assert m, f"Couldn't parse 'would backup as' from output.\n{output}" + would_backup_name = m.group(1) + + post_backups = { + p.name for p in target_file.parent.iterdir() + if p.is_file() and backup_re.match(p.name) + } + + assert would_backup_name not in post_backups, \ + f"Dry-run created backup unexpectedly: {would_backup_name}" + assert post_backups == pre_backups, \ + f"Dry-run should not create backups.\nBefore: {sorted(pre_backups)}\nAfter: {sorted(post_backups)}" + + with open(target_file, "r", encoding="utf-8", errors="ignore") as f: + assert marker in f.read(), "Marker should remain after dry-run." + + unexpected_dep = ["Installing Python dependencies", "Dependencies for xai_flask installed"] + assert not any(h.lower() in output.lower() for h in unexpected_dep), \ + "Dry-run should not install deps." + assert "dry-run.diff.txt" in output.lower(), \ + "Expected dry-run diff mention in output." + + + +def test_36_update_specific_ref(tmp_path): + """ + Specific ref to v1.17.0: + - '(ref=v1.17.0)' appears in output + - requirements.txt contains 'flask-cors==4.0.0' + """ + os.chdir(tmp_path) + + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + + lib_dir = Path("xai_components") / "xai_flask" + assert lib_dir.exists() + reqs = lib_dir / "requirements.txt" + assert reqs.exists() + + stdout, stderr, rc = run_command("xircuits update flask --ref v1.17.0", timeout=300) + output = (stdout or "") + (stderr or "") + assert rc == 0, f"Update with ref failed.\n{output}" + + req_text = reqs.read_text(encoding="utf-8", errors="ignore").lower() + assert ("(ref=v1.17.0)" in output.replace(" ", "")) and ("flask-cors==4.0.0" in req_text), \ + ( + "Strict ref update check failed:\n" + "- Expected '(ref=v1.17.0)' in CLI output AND 'flask-cors==4.0.0' in requirements.txt\n" + f"--- CLI output ---\n{output}\n" + f"--- requirements.txt ---\n{req_text}" + ) + + +def test_37_update_prune_archives_locals(tmp_path): + """ + Prune: + - add local-only file/dir + - 'xircuits update flask --prune' archives with timestamped .bak and removes originals + - subsequent dry-run must not suggest backups for these local names + """ + os.chdir(tmp_path) + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + + lib_dir = Path("xai_components") / "xai_flask" + assert lib_dir.exists() + + local_file = lib_dir / "LOCAL_ONLY.md" + local_dir = lib_dir / "local_extra" / "subdir" + local_dir.mkdir(parents=True, exist_ok=True) + local_file.write_text("local-only note", encoding="utf-8") + (local_dir / "keep.txt").write_text("keep me", encoding="utf-8") + + assert local_file.exists() + assert local_dir.exists() + + stdout, stderr, rc = run_command("xircuits update flask --prune", timeout=300) + output = (stdout or "") + (stderr or "") + assert "xai_flask update (" in output + + bak_suffix = r"\.\d{8}-\d{6}\.bak$" + + # file archived + file_bak_exists = any( + re.match(r"LOCAL_ONLY\.md" + bak_suffix, p.name) for p in lib_dir.iterdir() if p.is_file() + ) + assert file_bak_exists, "Expected a timestamped .bak for LOCAL_ONLY.md" + assert not local_file.exists(), "Original LOCAL_ONLY.md should be removed" + + # dir archived + dir_bak_exists = any( + (p.is_dir() and re.match(r"local_extra" + bak_suffix, p.name)) + for p in lib_dir.iterdir() + ) + assert dir_bak_exists, "Expected a timestamped .bak directory for local_extra/" + assert not (lib_dir / "local_extra").exists(), "Original local_extra/ should be removed" + + stdout, stderr, rc = run_command("xircuits update flask --dry-run", timeout=180) + dry = (stdout or "") + (stderr or "") + assert rc == 0, f"dry-run after prune failed:\n{dry}" + + for forbidden in ["LOCAL_ONLY.md", "local_extra/"]: + assert forbidden not in dry, f"Dry-run suggests local artifacts still present: {forbidden}" + + +def test_38_update_without_installing_deps(tmp_path): + """ + --install-deps=false: + - no pip output + - files updated (timestamped .bak) and marker removed + """ + os.chdir(tmp_path) + + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + + lib = Path("xai_components") / "xai_flask" + tgt = lib / "flask_components.py" + assert lib.exists() and tgt.exists() + + marker = "# LOCAL_MARKER_FOR_INSTALL_DEPS_FALSE\n" + with tgt.open("a", encoding="utf-8", errors="ignore") as w: + w.write(marker) + + stdout, stderr, rc = run_command("xircuits update flask --install-deps=false", timeout=300) + out = (stdout or "") + (stderr or "") + assert rc == 0, f"update --install-deps=false failed:\n{out}" + + forbidden = [ + "Installing Python dependencies", + "Installing collected packages", + "Requirement already satisfied", + "Successfully installed", + "Uninstalling", + ] + assert not any(s in out for s in forbidden), ( + "Expected NO pip activity with --install-deps=false, but found:\n" + + "\n".join(s for s in forbidden if s in out) + ) + + bak_suffix = r"\.\d{8}-\d{6}\.bak$" + bak_re = re.compile(r"^flask_components\.py" + bak_suffix) + baks = {p.name for p in lib.iterdir() if p.is_file() and bak_re.match(p.name)} + assert baks, "Expected timestamped .bak (files updated) but none found." + + now = tgt.read_text(encoding="utf-8", errors="ignore") + assert marker not in now, "Local marker still present; backup/restore failed." + + +def test_39_update_repo_override_with_ref(tmp_path): + """ + --repo + --ref: + - update from an explicit repo/ref should create .bak and remove local marker + """ + import re + from pathlib import Path + import os + + os.chdir(tmp_path) + + run_command("xircuits init", timeout=30) + run_command("xircuits install flask", timeout=180) + + lib = Path("xai_components") / "xai_flask" + tgt = lib / "flask_components.py" + assert tgt.exists() + + marker = "# LOCAL_MARKER_FOR_REPO_OVERRIDE\n" + tgt.write_text(tgt.read_text(encoding="utf-8", errors="ignore") + marker, encoding="utf-8") + + repo = "https://github.com/XpressAI/xai-flask" + cmd = f"xircuits update flask --repo {repo} --ref main" + stdout, stderr, rc = run_command(cmd, timeout=300) + out = (stdout or "") + (stderr or "") + assert rc == 0, f"--repo update failed:\n{out}" + + bak_suffix = r"\.\d{8}-\d{6}\.bak$" + bak_re = re.compile(r"^flask_components\.py" + bak_suffix) + baks = {p.name for p in lib.iterdir() if p.is_file() and bak_re.match(p.name)} + assert baks, "Expected .bak after repo override" + + assert marker not in tgt.read_text(encoding="utf-8", errors="ignore") From cdaad95e19e9c64c900946bd62641ef005a1ddd3 Mon Sep 17 00:00:00 2001 From: rabea-Al Date: Wed, 15 Oct 2025 03:04:06 +0800 Subject: [PATCH 17/18] over core libs no-overwrite + preserve locals w/o prune --- tests/cli_tests.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/cli_tests.py b/tests/cli_tests.py index 8727a0f7..dc8551ca 100644 --- a/tests/cli_tests.py +++ b/tests/cli_tests.py @@ -1001,3 +1001,92 @@ def test_39_update_repo_override_with_ref(tmp_path): assert baks, "Expected .bak after repo override" assert marker not in tgt.read_text(encoding="utf-8", errors="ignore") + +def test_40_core_update_no_overwrite(tmp_path): + """ + Core libs: 'xircuits update ' fails (non-cloneable /tree URL), + no backups created, and local edits remain intact. + """ + os.chdir(tmp_path) + + # init + stdout, stderr, rc = run_command("xircuits init", timeout=30) + + # use xai_utils as a core example + lib_dir = Path("xai_components") / "xai_utils" + target = lib_dir / "utils.py" + assert target.exists(), f"Missing core file: {target}" + + # add a local marker + marker = "# CORE_UPDATE_MARKER\n" + with target.open("a", encoding="utf-8", errors="ignore") as w: + w.write(marker) + + # snapshot existing .bak (should stay unchanged) + bak_re = re.compile(r"^utils\.py\.\d{8}-\d{6}\.bak$") + pre_baks = {p.name for p in lib_dir.iterdir() if p.is_file() and bak_re.match(p.name)} + + # run update (expected to FAIL for core libs) + stdout, stderr, rc = run_command("xircuits update utils", timeout=120) + out = (stdout or "") + (stderr or "") + + # expect non-zero exit and the tree-url failure hint + assert rc != 0, "Core update should fail with current /tree URL behavior." + assert ("tree/master" in out) or ("not found" in out.lower()) or ("returned non-zero exit status" in out.lower()), \ + f"Unexpected failure output:\n{out}" + + # no new backups created + post_baks = {p.name for p in lib_dir.iterdir() if p.is_file() and bak_re.match(p.name)} + assert post_baks == pre_baks, "Failure must not create .bak files for core libs." + + # local marker still there (no overwrite happened) + now = target.read_text(encoding="utf-8", errors="ignore") + assert marker in now, "Core file was modified unexpectedly on failed update." + +def test_41_update_preserves_local_artifacts_without_prune(tmp_path): + """ + Update (no --prune): + - local-only file/dir should remain (no .bak, no removal) + - tracked file with marker gets backed up & restored + """ + os.chdir(tmp_path) + + # init + install remote lib + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + + lib_dir = Path("xai_components") / "xai_flask" + target = lib_dir / "flask_components.py" + assert target.exists() + + # local-only artifacts + local_file = lib_dir / "LOCAL_ONLY.md" + local_dir = lib_dir / "local_extra" + local_file.write_text("local note", encoding="utf-8") + (local_dir / "keep.txt").parent.mkdir(parents=True, exist_ok=True) + (local_dir / "keep.txt").write_text("keep me", encoding="utf-8") + + # marker on a tracked file to force update path + marker = "# LOCAL_MARKER_NO_PRUNE\n" + with target.open("a", encoding="utf-8", errors="ignore") as w: + w.write(marker) + + # run normal update (NO --prune) + stdout, stderr, rc = run_command("xircuits update flask", timeout=300) + out = (stdout or "") + (stderr or "") + assert "xai_flask update (" in out + + # tracked file: expect timestamped .bak and marker removed + bak_re = re.compile(r"^flask_components\.py\.\d{8}-\d{6}\.bak$") + baks = {p.name for p in lib_dir.iterdir() if p.is_file() and bak_re.match(p.name)} + assert baks, "Expected .bak for tracked file" + assert marker not in target.read_text(encoding="utf-8", errors="ignore") + + # local-only artifacts: must still exist; no .bak created for them + assert local_file.exists(), "LOCAL_ONLY.md should remain without --prune" + assert local_dir.exists(), "local_extra/ should remain without --prune" + ts_suffix = r"\.\d{8}-\d{6}\.bak$" + assert not any(re.match(r"LOCAL_ONLY\.md" + ts_suffix, p.name) for p in lib_dir.iterdir()), \ + "Unexpected .bak for LOCAL_ONLY.md without --prune" + assert not any(p.is_dir() and re.match(r"local_extra" + ts_suffix, p.name) for p in lib_dir.iterdir()), \ + "Unexpected .bak dir for local_extra/ without --prune" From 35e358c5ec37af7fb136710b2cb5301fdeb9c0e1 Mon Sep 17 00:00:00 2001 From: MFA-X-AI Date: Wed, 18 Feb 2026 15:11:20 +0900 Subject: [PATCH 18/18] fix broken tests, update with new ones based on new cli --- tests/cli_tests.py | 186 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 174 insertions(+), 12 deletions(-) diff --git a/tests/cli_tests.py b/tests/cli_tests.py index dc8551ca..0402e0e6 100644 --- a/tests/cli_tests.py +++ b/tests/cli_tests.py @@ -1004,8 +1004,8 @@ def test_39_update_repo_override_with_ref(tmp_path): def test_40_core_update_no_overwrite(tmp_path): """ - Core libs: 'xircuits update ' fails (non-cloneable /tree URL), - no backups created, and local edits remain intact. + Core libs: 'xircuits update ' now SUCCEEDS by updating from wheel. + Should create backup and restore files (local marker removed). """ os.chdir(tmp_path) @@ -1022,26 +1022,26 @@ def test_40_core_update_no_overwrite(tmp_path): with target.open("a", encoding="utf-8", errors="ignore") as w: w.write(marker) - # snapshot existing .bak (should stay unchanged) + # snapshot existing .bak bak_re = re.compile(r"^utils\.py\.\d{8}-\d{6}\.bak$") pre_baks = {p.name for p in lib_dir.iterdir() if p.is_file() and bak_re.match(p.name)} - # run update (expected to FAIL for core libs) + # run update (now expected to SUCCEED for core libs - updates from wheel) stdout, stderr, rc = run_command("xircuits update utils", timeout=120) out = (stdout or "") + (stderr or "") - # expect non-zero exit and the tree-url failure hint - assert rc != 0, "Core update should fail with current /tree URL behavior." - assert ("tree/master" in out) or ("not found" in out.lower()) or ("returned non-zero exit status" in out.lower()), \ - f"Unexpected failure output:\n{out}" + # expect success - core libraries now update from wheel + assert rc == 0, f"Core update should succeed.\nOutput:\n{out}" + assert "xai_utils update (" in out, "Missing update summary in output." - # no new backups created + # backup should be created post_baks = {p.name for p in lib_dir.iterdir() if p.is_file() and bak_re.match(p.name)} - assert post_baks == pre_baks, "Failure must not create .bak files for core libs." + new_baks = post_baks - pre_baks + assert new_baks, "Expected backup to be created for core library update." - # local marker still there (no overwrite happened) + # local marker should be removed (file was restored from wheel) now = target.read_text(encoding="utf-8", errors="ignore") - assert marker in now, "Core file was modified unexpectedly on failed update." + assert marker not in now, "Local marker should be removed after core library update." def test_41_update_preserves_local_artifacts_without_prune(tmp_path): """ @@ -1090,3 +1090,165 @@ def test_41_update_preserves_local_artifacts_without_prune(tmp_path): "Unexpected .bak for LOCAL_ONLY.md without --prune" assert not any(p.is_dir() and re.match(r"local_extra" + ts_suffix, p.name) for p in lib_dir.iterdir()), \ "Unexpected .bak dir for local_extra/ without --prune" + + +def test_42_update_no_overwrite_flag(tmp_path): + """ + Test --no-overwrite flag: + - Modify a core library file with a local marker + - Run 'xircuits update utils --no-overwrite' + - Verify the ⊙ symbol appears in output (indicating local changes preserved) + - Verify NO backup is created for that file + - Verify marker still exists in file (local changes preserved) + """ + os.chdir(tmp_path) + + # init + stdout, stderr, rc = run_command("xircuits init", timeout=30) + + # use xai_utils as a core example + lib_dir = Path("xai_components") / "xai_utils" + target = lib_dir / "utils.py" + assert target.exists(), f"Missing core file: {target}" + + # add a local marker + marker = "# NO_OVERWRITE_MARKER\n" + with target.open("a", encoding="utf-8", errors="ignore") as w: + w.write(marker) + + # snapshot existing .bak + bak_re = re.compile(r"^utils\.py\.\d{8}-\d{6}\.bak$") + pre_baks = {p.name for p in lib_dir.iterdir() if p.is_file() and bak_re.match(p.name)} + + # run update with --no-overwrite + stdout, stderr, rc = run_command("xircuits update utils --no-overwrite", timeout=120) + out = (stdout or "") + (stderr or "") + + assert rc == 0, f"Update with --no-overwrite should succeed.\nOutput:\n{out}" + + # check for ⊙ symbol indicating local changes preserved + assert "⊙" in out, "Expected ⊙ symbol in output indicating local changes preserved." + + # no new backup should be created + post_baks = {p.name for p in lib_dir.iterdir() if p.is_file() and bak_re.match(p.name)} + new_baks = post_baks - pre_baks + assert not new_baks, "No backup should be created with --no-overwrite." + + # local marker should still exist (file not updated) + now = target.read_text(encoding="utf-8", errors="ignore") + assert marker in now, "Local marker should remain with --no-overwrite." + + +def test_43_update_core_only_flag(tmp_path): + """ + Test --core-only with --all: + - Install a remote library (e.g., flask) + - Run 'xircuits update --all --core-only --dry-run' + - Verify only core libraries are shown in preview + - Verify remote library (flask) is NOT shown + """ + os.chdir(tmp_path) + + # init + install remote library + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + assert rc == 0, "Failed to install flask library." + + # run update with --all --core-only --dry-run + stdout, stderr, rc = run_command("xircuits update --all --core-only --dry-run", timeout=120) + out = (stdout or "") + (stderr or "") + + assert rc == 0, f"Update --all --core-only --dry-run failed.\nOutput:\n{out}" + + # check for core libraries (xai_utils, xai_events, etc.) + assert "xai_utils" in out.lower() or "xai_events" in out.lower() or "xai_controlflow" in out.lower() or "xai_template" in out.lower(), \ + "Expected core libraries in output." + + # remote library should NOT be shown + assert "xai_flask" not in out.lower(), "Remote library (flask) should NOT appear with --core-only." + + +def test_44_update_remote_only_flag(tmp_path): + """ + Test --remote-only with --all: + - Install a remote library (e.g., flask) + - Run 'xircuits update --all --remote-only --dry-run' + - Verify only remote library is shown + - Verify core libraries are NOT shown + """ + os.chdir(tmp_path) + + # init + install remote library + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + assert rc == 0, "Failed to install flask library." + + # run update with --all --remote-only --dry-run + stdout, stderr, rc = run_command("xircuits update --all --remote-only --dry-run", timeout=120) + out = (stdout or "") + (stderr or "") + + assert rc == 0, f"Update --all --remote-only --dry-run failed.\nOutput:\n{out}" + + # remote library should be shown + assert "xai_flask" in out.lower(), "Expected remote library (flask) in output." + + # core libraries should NOT be shown + core_libs = ["xai_utils", "xai_events", "xai_controlflow", "xai_template"] + for core_lib in core_libs: + assert core_lib not in out.lower(), f"Core library {core_lib} should NOT appear with --remote-only." + + +def test_45_update_exclude_flag(tmp_path): + """ + Test --exclude flag: + - Install multiple remote libraries (e.g., flask, gradio) + - Run 'xircuits update --all --exclude=flask --dry-run' + - Verify excluded library is not in update list + """ + os.chdir(tmp_path) + + # init + install remote libraries + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + assert rc == 0, "Failed to install flask library." + + stdout, stderr, rc = run_command("xircuits install gradio", timeout=180) + assert rc == 0, "Failed to install gradio library." + + # run update with --exclude=flask + stdout, stderr, rc = run_command("xircuits update --all --exclude=flask --dry-run", timeout=120) + out = (stdout or "") + (stderr or "") + + assert rc == 0, f"Update with --exclude failed.\nOutput:\n{out}" + + # excluded library should not appear + assert "xai_flask" not in out.lower(), "Excluded library (flask) should NOT appear in output." + + # non-excluded library should appear + assert "xai_gradio" in out.lower(), "Non-excluded library (gradio) should appear in output." + + +def test_46_update_respect_refs_flag(tmp_path): + """ + Test --respect-refs flag: + - Install a library + - Run 'xircuits update --all --respect-refs --dry-run' + - Verify it works (flag doesn't break) + """ + os.chdir(tmp_path) + + # init + install remote library + stdout, stderr, rc = run_command("xircuits init", timeout=30) + stdout, stderr, rc = run_command("xircuits install flask", timeout=180) + assert rc == 0, "Failed to install flask library." + + # run update with --respect-refs --dry-run + stdout, stderr, rc = run_command("xircuits update --all --respect-refs --dry-run", timeout=120) + out = (stdout or "") + (stderr or "") + + assert rc == 0, f"Update with --respect-refs failed.\nOutput:\n{out}" + + # check that the command executed successfully (basic smoke test) + # The --respect-refs flag is harder to fully test without pinned versions, + # but we verify the flag doesn't break the command + assert "dry-run" in out.lower(), "Expected dry-run mode indication in output."