diff --git a/src/component_info_sidebar/ComponentPreviewWidget.tsx b/src/component_info_sidebar/ComponentPreviewWidget.tsx index a2e406b7..d4aee0bd 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,9 +359,47 @@ 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; + + // 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) { const node = this._model?.node; diff --git a/src/components/XircuitsBodyWidget.tsx b/src/components/XircuitsBodyWidget.tsx index eaaf1de5..4b7d5d4a 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, @@ -450,12 +454,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]); @@ -485,6 +514,7 @@ export const BodyWidget: FC = ({ return () => clearTimeout(timeout); }, linksUpdated: (event) => { + scheduleCanvasEmit(); const timeout = setTimeout(() => { event.link.registerListener({ sourcePortChanged: () => { @@ -508,6 +538,7 @@ export const BodyWidget: FC = ({ xircuitsApp.getDiagramEngine().setModel(deserializedModel); clearSearchFlags(); + // On the first load, clear undo history and register global engine listeners if (initialRender.current) { currentContext.model.sharedModel.clearUndoHistory(); 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/helpers/notificationEffects.ts b/src/helpers/notificationEffects.ts index e140763a..e96a623f 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 = candidateId.replace(/^xai_components[\/\\]/i, ''); + const idx = await loadLibraryIndex(); const entry = idx.get(candidateId); - return computeStatusFromEntry(entry, candidateId); + return computeStatusFromEntry(entry, cleanLibId); } export async function showInstallForRemoteLibrary(args: { diff --git a/src/index.tsx b/src/index.tsx index 25a6f769..a13771a4 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -33,9 +33,10 @@ import type { Signal } from "@lumino/signaling"; import { commandIDs } from "./commands/CommandIDs"; import { IEditorTracker } from '@jupyterlab/fileeditor'; import { IMainMenu } from '@jupyterlab/mainmenu'; -import { installLibrarySilently } from './context-menu/TrayContextMenu'; -import { normalizeLibraryName } from './tray_library/ComponentLibraryConfig'; +import { handleInstall, installLibrarySilently } from './context-menu/TrayContextMenu'; +import { augmentNotifications } from './helpers/notificationAugmentor'; import { loadLibraryIndex } from './helpers/notificationEffects'; +import { normalizeLibraryName } from './tray_library/ComponentLibraryConfig'; import { installComponentPreview } from './component_info_sidebar/previewHelper'; const FACTORY = 'Xircuits editor'; @@ -81,6 +82,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..8bdd090b 100644 --- a/style/base.css +++ b/style/base.css @@ -158,6 +158,16 @@ 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); +} \ No newline at end of file diff --git a/tests/cli_tests.py b/tests/cli_tests.py index 3a01bcc8..0402e0e6 100644 --- a/tests/cli_tests.py +++ b/tests/cli_tests.py @@ -728,3 +728,527 @@ 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") + +def test_40_core_update_no_overwrite(tmp_path): + """ + Core libs: 'xircuits update ' now SUCCEEDS by updating from wheel. + Should create backup and restore files (local marker removed). + """ + 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 + 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 (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 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." + + # 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 new_baks, "Expected backup to be created for core library update." + + # local marker should be removed (file was restored from wheel) + now = target.read_text(encoding="utf-8", errors="ignore") + 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): + """ + 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" + + +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." 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..41951286 100644 --- a/xircuits/library/update_library.py +++ b/xircuits/library/update_library.py @@ -6,11 +6,13 @@ from dataclasses import dataclass from pathlib import Path from typing import List, Optional, Set +from importlib_resources import files, as_file 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, @@ -26,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: @@ -48,48 +52,186 @@ def update_library( dry_run: bool = False, prune: bool = False, install_deps: bool = True, + use_latest: bool = False, + no_overwrite: bool = False, ) -> 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. + use_latest: If True, ignore metadata ref and pull latest from default branch. + no_overwrite: If True, skip updating files with local modifications. - 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, + no_overwrite=no_overwrite, + ) + + # 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, + no_overwrite=no_overwrite, + ) + + # Regular (non-core) library update from git + return _update_from_git( + lib_name=lib_name, + repo=repo, + ref=ref, + dry_run=dry_run, + prune=prune, + install_deps=install_deps, + use_latest=use_latest, + no_overwrite=no_overwrite, + ) + +def _update_from_wheel( + component_name: str, + working_dir: Path, + dry_run: bool, + prune: bool, + no_overwrite: bool = False, +) -> 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, no_overwrite) + else: + report = _sync_with_backups( + source_root=src_path, + destination_root=dst_path, + dry_run=dry_run, + prune=prune, + timestamp=timestamp, + no_overwrite=no_overwrite, + ) + + # 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, + repo: Optional[str], + ref: Optional[str], + dry_run: bool, + prune: bool, + install_deps: bool, + use_latest: bool = False, + no_overwrite: bool = False, +) -> str: + """ + 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( - 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") - 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 '{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." ) @@ -113,11 +255,10 @@ def update_library( dry_run=dry_run, prune=prune, timestamp=timestamp, + no_overwrite=no_overwrite, ) # 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( src_dir, dest_dir, @@ -136,7 +277,7 @@ def update_library( if not dry_run: try: record_component_metadata( - library_name=lib_name, # normalizes to xai-* + 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", @@ -152,7 +293,7 @@ def update_library( 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) + # Always refresh the per-library extra + meta extra on update try: set_library_extra(lib_name, reqs) rebuild_meta_extra("xai-components") @@ -184,17 +325,96 @@ def update_library( 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: + """ + 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, + no_overwrite: bool = False +) -> 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: + # File has local modifications + if no_overwrite: + 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) + + return SyncReport(added=added, updated=updated, deleted=deleted, unchanged=unchanged) + + +# ========== Git Update Helpers ========== 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: @@ -202,7 +422,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) @@ -298,6 +524,7 @@ 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). @@ -306,6 +533,7 @@ def _sync_with_backups( +++ 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] = [] @@ -331,6 +559,12 @@ def _sync_with_backups( 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})") @@ -400,6 +634,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`. @@ -418,6 +653,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 @@ -433,6 +671,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, @@ -481,3 +734,193 @@ 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, + no_overwrite: 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) + no_overwrite: Skip updating files with local modifications + + 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, + no_overwrite=no_overwrite, + ) + + 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..974087da 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,49 @@ 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, + no_overwrite=args.no_overwrite, + ) + + except Exception as e: + print(f"Error: {e}") + 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, + ref=args.ref, + dry_run=args.dry_run, + prune=args.prune, + install_deps=args.install_deps, + no_overwrite=args.no_overwrite, + ) + print(message) def cmd_run(args, extra_args=[]): original_cwd = args.original_cwd @@ -276,15 +309,30 @@ 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.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) # 'run' command.