From 8e76f8b029afdb94991f83c5bd04e57b291ee921 Mon Sep 17 00:00:00 2001 From: MFA-X-AI Date: Tue, 7 Oct 2025 01:37:38 +0900 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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)