griptape-nodes 0.64.11__py3-none-any.whl → 0.65.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. griptape_nodes/app/app.py +25 -5
  2. griptape_nodes/cli/commands/init.py +65 -54
  3. griptape_nodes/cli/commands/libraries.py +92 -85
  4. griptape_nodes/cli/commands/self.py +121 -0
  5. griptape_nodes/common/node_executor.py +2142 -101
  6. griptape_nodes/exe_types/base_iterative_nodes.py +1004 -0
  7. griptape_nodes/exe_types/connections.py +114 -19
  8. griptape_nodes/exe_types/core_types.py +225 -7
  9. griptape_nodes/exe_types/flow.py +3 -3
  10. griptape_nodes/exe_types/node_types.py +681 -225
  11. griptape_nodes/exe_types/param_components/README.md +414 -0
  12. griptape_nodes/exe_types/param_components/api_key_provider_parameter.py +200 -0
  13. griptape_nodes/exe_types/param_components/huggingface/huggingface_model_parameter.py +2 -0
  14. griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_file_parameter.py +79 -5
  15. griptape_nodes/exe_types/param_types/parameter_button.py +443 -0
  16. griptape_nodes/machines/control_flow.py +84 -38
  17. griptape_nodes/machines/dag_builder.py +148 -70
  18. griptape_nodes/machines/parallel_resolution.py +61 -35
  19. griptape_nodes/machines/sequential_resolution.py +11 -113
  20. griptape_nodes/retained_mode/events/app_events.py +1 -0
  21. griptape_nodes/retained_mode/events/base_events.py +16 -13
  22. griptape_nodes/retained_mode/events/connection_events.py +3 -0
  23. griptape_nodes/retained_mode/events/execution_events.py +35 -0
  24. griptape_nodes/retained_mode/events/flow_events.py +15 -2
  25. griptape_nodes/retained_mode/events/library_events.py +347 -0
  26. griptape_nodes/retained_mode/events/node_events.py +48 -0
  27. griptape_nodes/retained_mode/events/os_events.py +86 -3
  28. griptape_nodes/retained_mode/events/project_events.py +15 -1
  29. griptape_nodes/retained_mode/events/workflow_events.py +48 -1
  30. griptape_nodes/retained_mode/griptape_nodes.py +6 -2
  31. griptape_nodes/retained_mode/managers/config_manager.py +10 -8
  32. griptape_nodes/retained_mode/managers/event_manager.py +168 -0
  33. griptape_nodes/retained_mode/managers/fitness_problems/libraries/__init__.py +2 -0
  34. griptape_nodes/retained_mode/managers/fitness_problems/libraries/old_xdg_location_warning_problem.py +43 -0
  35. griptape_nodes/retained_mode/managers/flow_manager.py +664 -123
  36. griptape_nodes/retained_mode/managers/library_manager.py +1142 -138
  37. griptape_nodes/retained_mode/managers/model_manager.py +2 -3
  38. griptape_nodes/retained_mode/managers/node_manager.py +148 -25
  39. griptape_nodes/retained_mode/managers/object_manager.py +3 -1
  40. griptape_nodes/retained_mode/managers/operation_manager.py +3 -1
  41. griptape_nodes/retained_mode/managers/os_manager.py +1158 -122
  42. griptape_nodes/retained_mode/managers/secrets_manager.py +2 -3
  43. griptape_nodes/retained_mode/managers/settings.py +21 -1
  44. griptape_nodes/retained_mode/managers/sync_manager.py +2 -3
  45. griptape_nodes/retained_mode/managers/workflow_manager.py +358 -104
  46. griptape_nodes/retained_mode/retained_mode.py +3 -3
  47. griptape_nodes/traits/button.py +44 -2
  48. griptape_nodes/traits/file_system_picker.py +2 -2
  49. griptape_nodes/utils/file_utils.py +101 -0
  50. griptape_nodes/utils/git_utils.py +1236 -0
  51. griptape_nodes/utils/library_utils.py +122 -0
  52. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.1.dist-info}/METADATA +2 -1
  53. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.1.dist-info}/RECORD +55 -47
  54. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.1.dist-info}/WHEEL +1 -1
  55. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.1.dist-info}/entry_points.txt +0 -0
@@ -13,7 +13,7 @@ from collections import defaultdict
13
13
  from dataclasses import dataclass, field
14
14
  from importlib.resources import files
15
15
  from pathlib import Path
16
- from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
16
+ from typing import TYPE_CHECKING, Any, Generic, NamedTuple, TypeVar, cast
17
17
 
18
18
  from packaging.requirements import InvalidRequirement, Requirement
19
19
  from pydantic import ValidationError
@@ -23,6 +23,7 @@ from rich.console import Console
23
23
  from rich.panel import Panel
24
24
  from rich.table import Table
25
25
  from rich.text import Text
26
+ from semver import Version
26
27
  from xdg_base_dirs import xdg_data_home
27
28
 
28
29
  from griptape_nodes.exe_types.node_types import BaseNode
@@ -45,7 +46,7 @@ from griptape_nodes.retained_mode.events.app_events import (
45
46
  )
46
47
 
47
48
  # Runtime imports for ResultDetails since it's used at runtime
48
- from griptape_nodes.retained_mode.events.base_events import AppEvent, ResultDetails
49
+ from griptape_nodes.retained_mode.events.base_events import AppEvent, ResultDetails, ResultPayloadFailure
49
50
  from griptape_nodes.retained_mode.events.config_events import (
50
51
  GetConfigCategoryRequest,
51
52
  GetConfigCategoryResultSuccess,
@@ -53,6 +54,12 @@ from griptape_nodes.retained_mode.events.config_events import (
53
54
  SetConfigCategoryResultSuccess,
54
55
  )
55
56
  from griptape_nodes.retained_mode.events.library_events import (
57
+ CheckLibraryUpdateRequest,
58
+ CheckLibraryUpdateResultFailure,
59
+ CheckLibraryUpdateResultSuccess,
60
+ DownloadLibraryRequest,
61
+ DownloadLibraryResultFailure,
62
+ DownloadLibraryResultSuccess,
56
63
  GetAllInfoForAllLibrariesRequest,
57
64
  GetAllInfoForAllLibrariesResultFailure,
58
65
  GetAllInfoForAllLibrariesResultSuccess,
@@ -65,6 +72,12 @@ from griptape_nodes.retained_mode.events.library_events import (
65
72
  GetNodeMetadataFromLibraryRequest,
66
73
  GetNodeMetadataFromLibraryResultFailure,
67
74
  GetNodeMetadataFromLibraryResultSuccess,
75
+ InspectLibraryRepoRequest,
76
+ InspectLibraryRepoResultFailure,
77
+ InspectLibraryRepoResultSuccess,
78
+ InstallLibraryDependenciesRequest,
79
+ InstallLibraryDependenciesResultFailure,
80
+ InstallLibraryDependenciesResultSuccess,
68
81
  ListCapableLibraryEventHandlersRequest,
69
82
  ListCapableLibraryEventHandlersResultFailure,
70
83
  ListCapableLibraryEventHandlersResultSuccess,
@@ -93,11 +106,24 @@ from griptape_nodes.retained_mode.events.library_events import (
93
106
  ReloadAllLibrariesRequest,
94
107
  ReloadAllLibrariesResultFailure,
95
108
  ReloadAllLibrariesResultSuccess,
109
+ SwitchLibraryRefRequest,
110
+ SwitchLibraryRefResultFailure,
111
+ SwitchLibraryRefResultSuccess,
112
+ SyncLibrariesRequest,
113
+ SyncLibrariesResultFailure,
114
+ SyncLibrariesResultSuccess,
96
115
  UnloadLibraryFromRegistryRequest,
97
116
  UnloadLibraryFromRegistryResultFailure,
98
117
  UnloadLibraryFromRegistryResultSuccess,
118
+ UpdateLibraryRequest,
119
+ UpdateLibraryResultFailure,
120
+ UpdateLibraryResultSuccess,
99
121
  )
100
122
  from griptape_nodes.retained_mode.events.object_events import ClearAllObjectStateRequest
123
+ from griptape_nodes.retained_mode.events.os_events import (
124
+ DeleteFileRequest,
125
+ DeleteFileResultFailure,
126
+ )
101
127
  from griptape_nodes.retained_mode.events.payload_registry import PayloadRegistry
102
128
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
103
129
  from griptape_nodes.retained_mode.managers.fitness_problems.libraries import (
@@ -105,10 +131,8 @@ from griptape_nodes.retained_mode.managers.fitness_problems.libraries import (
105
131
  AfterLibraryCallbackProblem,
106
132
  BeforeLibraryCallbackProblem,
107
133
  CreateConfigCategoryProblem,
108
- DependencyInstallationFailedProblem,
109
134
  DuplicateLibraryProblem,
110
135
  EngineVersionErrorProblem,
111
- InsufficientDiskSpaceProblem,
112
136
  InvalidVersionStringProblem,
113
137
  LibraryJsonDecodeProblem,
114
138
  LibraryLoadExceptionProblem,
@@ -119,9 +143,9 @@ from griptape_nodes.retained_mode.managers.fitness_problems.libraries import (
119
143
  NodeClassNotBaseNodeProblem,
120
144
  NodeClassNotFoundProblem,
121
145
  NodeModuleImportProblem,
146
+ OldXdgLocationWarningProblem,
122
147
  SandboxDirectoryMissingProblem,
123
148
  UpdateConfigCategoryProblem,
124
- VenvCreationFailedProblem,
125
149
  )
126
150
  from griptape_nodes.retained_mode.managers.library_lifecycle.library_directory import LibraryDirectory
127
151
  from griptape_nodes.retained_mode.managers.library_lifecycle.library_provenance.local_file import (
@@ -129,8 +153,32 @@ from griptape_nodes.retained_mode.managers.library_lifecycle.library_provenance.
129
153
  )
130
154
  from griptape_nodes.retained_mode.managers.library_lifecycle.library_status import LibraryStatus
131
155
  from griptape_nodes.retained_mode.managers.os_manager import OSManager
156
+ from griptape_nodes.retained_mode.managers.settings import LIBRARIES_TO_DOWNLOAD_KEY, LIBRARIES_TO_REGISTER_KEY
132
157
  from griptape_nodes.utils.async_utils import subprocess_run
133
158
  from griptape_nodes.utils.dict_utils import merge_dicts
159
+ from griptape_nodes.utils.file_utils import find_file_in_directory
160
+ from griptape_nodes.utils.git_utils import (
161
+ GitCloneError,
162
+ GitPullError,
163
+ GitRefError,
164
+ GitRemoteError,
165
+ GitRepositoryError,
166
+ clone_repository,
167
+ extract_repo_name_from_url,
168
+ get_current_ref,
169
+ get_git_remote,
170
+ get_local_commit_sha,
171
+ is_git_url,
172
+ parse_git_url_with_ref,
173
+ switch_branch_or_tag,
174
+ update_library_git,
175
+ )
176
+ from griptape_nodes.utils.library_utils import (
177
+ LIBRARY_GIT_URLS,
178
+ clone_and_get_library_version,
179
+ filter_old_xdg_library_paths,
180
+ is_monorepo,
181
+ )
134
182
  from griptape_nodes.utils.uv_utils import find_uv_bin
135
183
  from griptape_nodes.utils.version_utils import get_complete_version_string
136
184
 
@@ -151,6 +199,32 @@ EXCLUDED_SCAN_DIRECTORIES = frozenset({"venv", "__pycache__"})
151
199
  TRegisteredEventData = TypeVar("TRegisteredEventData")
152
200
 
153
201
 
202
+ class LibraryGitOperationContext(NamedTuple):
203
+ """Context information for git operations on a library."""
204
+
205
+ library: Library
206
+ old_version: str
207
+ library_file_path: str
208
+ library_dir: Path
209
+
210
+
211
+ class LibraryUpdateInfo(NamedTuple):
212
+ """Information about a library pending update."""
213
+
214
+ library_name: str
215
+ old_version: str
216
+ new_version: str
217
+
218
+
219
+ class LibraryUpdateResult(NamedTuple):
220
+ """Result of updating a single library."""
221
+
222
+ library_name: str
223
+ old_version: str
224
+ new_version: str
225
+ result: ResultPayload
226
+
227
+
154
228
  class LibraryManager:
155
229
  SANDBOX_LIBRARY_NAME = "Sandbox Library"
156
230
  LIBRARY_CONFIG_FILENAME = "griptape_nodes_library.json"
@@ -258,6 +332,15 @@ class LibraryManager:
258
332
  )
259
333
  event_manager.assign_manager_to_request_type(ReloadAllLibrariesRequest, self.reload_libraries_request)
260
334
  event_manager.assign_manager_to_request_type(LoadLibrariesRequest, self.load_libraries_request)
335
+ event_manager.assign_manager_to_request_type(CheckLibraryUpdateRequest, self.check_library_update_request)
336
+ event_manager.assign_manager_to_request_type(UpdateLibraryRequest, self.update_library_request)
337
+ event_manager.assign_manager_to_request_type(SwitchLibraryRefRequest, self.switch_library_ref_request)
338
+ event_manager.assign_manager_to_request_type(DownloadLibraryRequest, self.download_library_request)
339
+ event_manager.assign_manager_to_request_type(
340
+ InstallLibraryDependenciesRequest, self.install_library_dependencies_request
341
+ )
342
+ event_manager.assign_manager_to_request_type(SyncLibrariesRequest, self.sync_libraries_request)
343
+ event_manager.assign_manager_to_request_type(InspectLibraryRepoRequest, self.inspect_library_repo_request)
261
344
 
262
345
  event_manager.add_listener_to_app_event(
263
346
  AppInitializationComplete,
@@ -420,7 +503,6 @@ class LibraryManager:
420
503
  library = LibraryRegistry.get_library(name=request.library)
421
504
  except KeyError:
422
505
  details = f"Attempted to list node types in a Library named '{request.library}'. Failed because no Library with that name was registered."
423
- logger.error(details)
424
506
 
425
507
  result = ListNodeTypesInLibraryResultFailure(result_details=details)
426
508
  return result
@@ -443,7 +525,6 @@ class LibraryManager:
443
525
  library = LibraryRegistry.get_library(name=request.library)
444
526
  except KeyError:
445
527
  details = f"Attempted to get metadata for Library '{request.library}'. Failed because no Library with that name was registered."
446
- logger.error(details)
447
528
 
448
529
  result = GetLibraryMetadataResultFailure(result_details=details)
449
530
  return result
@@ -469,7 +550,6 @@ class LibraryManager:
469
550
  # Check if the file exists
470
551
  if not json_path.exists():
471
552
  details = f"Attempted to load Library JSON file. Failed because no file could be found at the specified path: {json_path}"
472
- logger.error(details)
473
553
  return LoadLibraryMetadataFromFileResultFailure(
474
554
  library_path=file_path,
475
555
  library_name=None,
@@ -484,7 +564,6 @@ class LibraryManager:
484
564
  library_json = json.load(f)
485
565
  except json.JSONDecodeError:
486
566
  details = f"Attempted to load Library JSON file. Failed because the file at path '{json_path}' was improperly formatted."
487
- logger.error(details)
488
567
  return LoadLibraryMetadataFromFileResultFailure(
489
568
  library_path=file_path,
490
569
  library_name=None,
@@ -494,7 +573,6 @@ class LibraryManager:
494
573
  )
495
574
  except Exception as err:
496
575
  details = f"Attempted to load Library JSON file from location '{json_path}'. Failed because an exception occurred: {err}"
497
- logger.error(details)
498
576
  return LoadLibraryMetadataFromFileResultFailure(
499
577
  library_path=file_path,
500
578
  library_name=None,
@@ -519,7 +597,6 @@ class LibraryManager:
519
597
  problem = LibrarySchemaValidationProblem(location=loc, error_type=error_type, message=msg)
520
598
  problems.append(problem)
521
599
  details = f"Attempted to load Library JSON file. Failed because the file at path '{json_path}' failed to match the library schema due to: {err}"
522
- logger.error(details)
523
600
  return LoadLibraryMetadataFromFileResultFailure(
524
601
  library_path=file_path,
525
602
  library_name=library_name,
@@ -529,7 +606,6 @@ class LibraryManager:
529
606
  )
530
607
  except Exception as err:
531
608
  details = f"Attempted to load Library JSON file. Failed because the file at path '{json_path}' failed to match the library schema due to: {err}"
532
- logger.error(details)
533
609
  return LoadLibraryMetadataFromFileResultFailure(
534
610
  library_path=file_path,
535
611
  library_name=library_name,
@@ -538,9 +614,22 @@ class LibraryManager:
538
614
  result_details=details,
539
615
  )
540
616
 
617
+ # Get git remote and ref if this library is in a git repository
618
+ library_dir = json_path.parent.absolute()
619
+ git_remote = get_git_remote(library_dir)
620
+ try:
621
+ git_ref = get_current_ref(library_dir)
622
+ except GitRefError as e:
623
+ logger.debug("Failed to get git ref for %s: %s", library_dir, e)
624
+ git_ref = None
625
+
541
626
  details = f"Successfully loaded library metadata from JSON file at {json_path}"
542
627
  return LoadLibraryMetadataFromFileResultSuccess(
543
- library_schema=library_data, file_path=file_path, result_details=details
628
+ library_schema=library_data,
629
+ file_path=file_path,
630
+ git_remote=git_remote,
631
+ git_ref=git_ref,
632
+ result_details=details,
544
633
  )
545
634
 
546
635
  def load_metadata_for_all_libraries_request(self, request: LoadMetadataForAllLibrariesRequest) -> ResultPayload: # noqa: ARG002
@@ -686,9 +775,21 @@ class LibraryManager:
686
775
  nodes=node_definitions,
687
776
  )
688
777
 
778
+ # Get git remote and ref if the sandbox directory is in a git repository
779
+ git_remote = get_git_remote(sandbox_library_dir)
780
+ try:
781
+ git_ref = get_current_ref(sandbox_library_dir)
782
+ except GitRefError as e:
783
+ logger.debug("Failed to get git ref for sandbox library %s: %s", sandbox_library_dir, e)
784
+ git_ref = None
785
+
689
786
  details = f"Successfully generated sandbox library metadata with {len(node_definitions)} nodes from {sandbox_library_dir}"
690
787
  return LoadLibraryMetadataFromFileResultSuccess(
691
- library_schema=library_schema, file_path=str(sandbox_library_dir), result_details=details
788
+ library_schema=library_schema,
789
+ file_path=str(sandbox_library_dir),
790
+ git_remote=git_remote,
791
+ git_ref=git_ref,
792
+ result_details=details,
692
793
  )
693
794
 
694
795
  def get_node_metadata_from_library_request(self, request: GetNodeMetadataFromLibraryRequest) -> ResultPayload:
@@ -722,7 +823,6 @@ class LibraryManager:
722
823
  library = LibraryRegistry.get_library(name=request.library)
723
824
  except KeyError:
724
825
  details = f"Attempted to get categories in a Library named '{request.library}'. Failed because no Library with that name was registered."
725
- logger.error(details)
726
826
  result = ListCategoriesInLibraryResultFailure(result_details=details)
727
827
  return result
728
828
 
@@ -747,7 +847,6 @@ class LibraryManager:
747
847
  problems=[LibraryNotFoundProblem(library_path=file_path)],
748
848
  )
749
849
  details = f"Attempted to load Library JSON file. Failed because no file could be found at the specified path: {json_path}"
750
- logger.error(details)
751
850
  return RegisterLibraryFromFileResultFailure(result_details=details)
752
851
 
753
852
  # Use the new metadata loading functionality
@@ -779,7 +878,6 @@ class LibraryManager:
779
878
  problems=[InvalidVersionStringProblem(version_string=str(library_data.metadata.library_version))],
780
879
  )
781
880
  details = f"Attempted to load Library '{library_data.name}' JSON file from '{json_path}'. Failed because version string '{library_data.metadata.library_version}' wasn't valid. Must be in major.minor.patch format."
782
- logger.error(details)
783
881
  return RegisterLibraryFromFileResultFailure(result_details=details)
784
882
 
785
883
  # Get the directory containing the JSON file to resolve relative paths
@@ -787,6 +885,39 @@ class LibraryManager:
787
885
  # Add the directory to the Python path to allow for relative imports
788
886
  sys.path.insert(0, str(base_dir))
789
887
 
888
+ # Install dependencies and add library venv to sys.path if library has dependencies
889
+ if library_data.metadata.dependencies and library_data.metadata.dependencies.pip_dependencies:
890
+ venv_path = self._get_library_venv_path(library_data.name, file_path)
891
+
892
+ install_request = InstallLibraryDependenciesRequest(library_file_path=file_path)
893
+ install_result = await self.install_library_dependencies_request(install_request)
894
+
895
+ if isinstance(install_result, InstallLibraryDependenciesResultFailure):
896
+ details = (
897
+ f"Failed to install dependencies for library '{library_data.name}': {install_result.result_details}"
898
+ )
899
+ return RegisterLibraryFromFileResultFailure(result_details=details)
900
+
901
+ if isinstance(install_result, InstallLibraryDependenciesResultSuccess):
902
+ logger.info(
903
+ "Installed %d dependencies for library '%s'",
904
+ install_result.dependencies_installed,
905
+ library_data.name,
906
+ )
907
+
908
+ # Add venv site-packages to sys.path so node imports can find dependencies
909
+ if venv_path.exists():
910
+ site_packages = str(
911
+ Path(
912
+ sysconfig.get_path(
913
+ "purelib",
914
+ vars={"base": str(venv_path), "platbase": str(venv_path)},
915
+ )
916
+ )
917
+ )
918
+ sys.path.insert(0, site_packages)
919
+ logger.debug("Added library '%s' venv to sys.path: %s", library_data.name, site_packages)
920
+
790
921
  # Load the advanced library module if specified
791
922
  advanced_library_instance = None
792
923
  if library_data.advanced_library_path:
@@ -808,7 +939,6 @@ class LibraryManager:
808
939
  ],
809
940
  )
810
941
  details = f"Attempted to load Library '{library_data.name}' from '{json_path}'. Failed to load Advanced Library module: {err}"
811
- logger.error(details)
812
942
  return RegisterLibraryFromFileResultFailure(result_details=details)
813
943
 
814
944
  # Create or get the library
@@ -831,91 +961,6 @@ class LibraryManager:
831
961
  )
832
962
 
833
963
  details = f"Attempted to load Library JSON file from '{json_path}'. Failed because a Library '{library_data.name}' already exists. Error: {err}."
834
- logger.error(details)
835
- return RegisterLibraryFromFileResultFailure(result_details=details)
836
-
837
- # Install node library dependencies
838
- try:
839
- if library_data.metadata.dependencies and library_data.metadata.dependencies.pip_dependencies:
840
- pip_install_flags = library_data.metadata.dependencies.pip_install_flags
841
- if pip_install_flags is None:
842
- pip_install_flags = []
843
- pip_dependencies = library_data.metadata.dependencies.pip_dependencies
844
-
845
- # Determine venv path for dependency installation
846
- venv_path = self._get_library_venv_path(library_data.name, file_path)
847
-
848
- # Only install dependencies if conditions are met
849
- try:
850
- library_venv_python_path = await self._init_library_venv(venv_path)
851
- except RuntimeError as e:
852
- self._library_file_path_to_info[file_path] = LibraryManager.LibraryInfo(
853
- library_path=file_path,
854
- library_name=library_data.name,
855
- library_version=library_version,
856
- status=LibraryStatus.UNUSABLE,
857
- problems=[VenvCreationFailedProblem(error_message=str(e))],
858
- )
859
- details = f"Attempted to load Library JSON file from '{json_path}'. Failed when creating the virtual environment: {e}."
860
- logger.error(details)
861
- return RegisterLibraryFromFileResultFailure(result_details=details)
862
- if self._can_write_to_venv_location(library_venv_python_path):
863
- # Check disk space before installing dependencies
864
- config_manager = GriptapeNodes.ConfigManager()
865
- min_space_gb = config_manager.get_config_value("minimum_disk_space_gb_libraries")
866
- if not OSManager.check_available_disk_space(Path(venv_path), min_space_gb):
867
- error_msg = OSManager.format_disk_space_error(Path(venv_path))
868
- details = f"Attempted to load Library JSON from '{json_path}'. Failed when installing dependencies due to insufficient disk space (requires {min_space_gb} GB): {error_msg}"
869
- logger.error(details)
870
- self._library_file_path_to_info[file_path] = LibraryManager.LibraryInfo(
871
- library_path=file_path,
872
- library_name=library_data.name,
873
- library_version=library_version,
874
- status=LibraryStatus.UNUSABLE,
875
- problems=[InsufficientDiskSpaceProblem(min_space_gb=min_space_gb, error_message=error_msg)],
876
- )
877
- return RegisterLibraryFromFileResultFailure(result_details=details)
878
-
879
- # Grab the python executable from the virtual environment so that we can pip install there
880
- logger.info(
881
- "Installing dependencies for library '%s' with pip in venv at %s", library_data.name, venv_path
882
- )
883
- is_debug = config_manager.get_config_value("log_level").upper() == "DEBUG"
884
- await subprocess_run(
885
- [
886
- sys.executable,
887
- "-m",
888
- "uv",
889
- "pip",
890
- "install",
891
- *pip_dependencies,
892
- *pip_install_flags,
893
- "--python",
894
- str(library_venv_python_path),
895
- ],
896
- check=True,
897
- capture_output=not is_debug,
898
- text=True,
899
- )
900
- else:
901
- logger.debug(
902
- "Skipping dependency installation for library '%s' - venv location at %s is not writable",
903
- library_data.name,
904
- venv_path,
905
- )
906
- except subprocess.CalledProcessError as e:
907
- # Failed to create the library
908
- error_details = f"return code={e.returncode}, stdout={e.stdout}, stderr={e.stderr}"
909
-
910
- self._library_file_path_to_info[file_path] = LibraryManager.LibraryInfo(
911
- library_path=file_path,
912
- library_name=library_data.name,
913
- library_version=library_version,
914
- status=LibraryStatus.UNUSABLE,
915
- problems=[DependencyInstallationFailedProblem(error_details=error_details)],
916
- )
917
- details = f"Attempted to load Library JSON file from '{json_path}'. Failed when installing dependencies: {error_details}"
918
- logger.error(details)
919
964
  return RegisterLibraryFromFileResultFailure(result_details=details)
920
965
 
921
966
  # We are at least potentially viable.
@@ -927,7 +972,10 @@ class LibraryManager:
927
972
  # Assign them into the config space.
928
973
  for library_data_setting in library_data.settings:
929
974
  # Does the category exist?
930
- get_category_request = GetConfigCategoryRequest(category=library_data_setting.category)
975
+ get_category_request = GetConfigCategoryRequest(
976
+ category=library_data_setting.category,
977
+ failure_log_level=logging.DEBUG, # Missing category is expected, suppress error toast
978
+ )
931
979
  get_category_result = GriptapeNodes.handle_request(get_category_request)
932
980
  if not isinstance(get_category_result, GetConfigCategoryResultSuccess):
933
981
  # That's OK, we'll invent it. Or at least we'll try.
@@ -983,7 +1031,6 @@ class LibraryManager:
983
1031
  return RegisterLibraryFromFileResultFailure(result_details=details)
984
1032
  case _:
985
1033
  details = f"Attempted to load Library JSON file from '{json_path}'. Failed because an unknown/unexpected status '{library_load_results.status}' was returned."
986
- logger.error(details)
987
1034
  return RegisterLibraryFromFileResultFailure(result_details=details)
988
1035
 
989
1036
  async def register_library_from_requirement_specifier_request(
@@ -994,21 +1041,29 @@ class LibraryManager:
994
1041
  # Determine venv path for dependency installation
995
1042
  venv_path = self._get_library_venv_path(package_name, None)
996
1043
 
1044
+ # Check if venv already exists before initialization
1045
+ venv_already_exists = venv_path.exists()
1046
+
997
1047
  # Only install dependencies if conditions are met
998
1048
  try:
999
1049
  library_python_venv_path = await self._init_library_venv(venv_path)
1000
1050
  except RuntimeError as e:
1001
1051
  details = f"Attempted to install library '{request.requirement_specifier}'. Failed when creating the virtual environment: {e}"
1002
- logger.error(details)
1003
1052
  return RegisterLibraryFromRequirementSpecifierResultFailure(result_details=details)
1004
- if self._can_write_to_venv_location(library_python_venv_path):
1053
+
1054
+ if venv_already_exists:
1055
+ logger.debug(
1056
+ "Skipping dependency installation for package '%s' - venv already exists at %s",
1057
+ package_name,
1058
+ venv_path,
1059
+ )
1060
+ elif self._can_write_to_venv_location(library_python_venv_path):
1005
1061
  # Check disk space before installing dependencies
1006
1062
  config_manager = GriptapeNodes.ConfigManager()
1007
1063
  min_space_gb = config_manager.get_config_value("minimum_disk_space_gb_libraries")
1008
1064
  if not OSManager.check_available_disk_space(Path(venv_path), min_space_gb):
1009
1065
  error_msg = OSManager.format_disk_space_error(Path(venv_path))
1010
1066
  details = f"Attempted to install library '{request.requirement_specifier}'. Failed when installing dependencies due to insufficient disk space (requires {min_space_gb} GB): {error_msg}"
1011
- logger.error(details)
1012
1067
  return RegisterLibraryFromRequirementSpecifierResultFailure(result_details=details)
1013
1068
 
1014
1069
  uv_path = find_uv_bin()
@@ -1036,11 +1091,9 @@ class LibraryManager:
1036
1091
  )
1037
1092
  except subprocess.CalledProcessError as e:
1038
1093
  details = f"Attempted to install library '{request.requirement_specifier}'. Failed: return code={e.returncode}, stdout={e.stdout}, stderr={e.stderr}"
1039
- logger.error(details)
1040
1094
  return RegisterLibraryFromRequirementSpecifierResultFailure(result_details=details)
1041
1095
  except InvalidRequirement as e:
1042
1096
  details = f"Attempted to install library '{request.requirement_specifier}'. Failed due to invalid requirement specifier: {e}"
1043
- logger.error(details)
1044
1097
  return RegisterLibraryFromRequirementSpecifierResultFailure(result_details=details)
1045
1098
 
1046
1099
  library_path = str(files(package_name).joinpath(request.library_config_name))
@@ -1048,7 +1101,6 @@ class LibraryManager:
1048
1101
  register_result = GriptapeNodes.handle_request(RegisterLibraryFromFileRequest(file_path=library_path))
1049
1102
  if isinstance(register_result, RegisterLibraryFromFileResultFailure):
1050
1103
  details = f"Attempted to install library '{request.requirement_specifier}'. Failed due to {register_result}"
1051
- logger.error(details)
1052
1104
  return RegisterLibraryFromRequirementSpecifierResultFailure(result_details=details)
1053
1105
 
1054
1106
  return RegisterLibraryFromRequirementSpecifierResultSuccess(
@@ -1110,17 +1162,6 @@ class LibraryManager:
1110
1162
  else:
1111
1163
  library_venv_python_path = library_venv_path / "bin" / "python"
1112
1164
 
1113
- # Need to insert into the path so that the library picks up on the venv
1114
- site_packages = str(
1115
- Path(
1116
- sysconfig.get_path(
1117
- "purelib",
1118
- vars={"base": str(library_venv_path), "platbase": str(library_venv_path)},
1119
- )
1120
- )
1121
- )
1122
- sys.path.insert(0, site_packages)
1123
-
1124
1165
  return library_venv_python_path
1125
1166
 
1126
1167
  def _get_library_venv_path(self, library_name: str, library_file_path: str | None = None) -> Path:
@@ -1179,7 +1220,6 @@ class LibraryManager:
1179
1220
  LibraryRegistry.unregister_library(library_name=request.library_name)
1180
1221
  except Exception as e:
1181
1222
  details = f"Attempted to unload library '{request.library_name}'. Failed due to {e}"
1182
- logger.error(details)
1183
1223
  return UnloadLibraryFromRegistryResultFailure(result_details=details)
1184
1224
 
1185
1225
  # Clean up all stable module aliases for this library
@@ -1199,7 +1239,6 @@ class LibraryManager:
1199
1239
 
1200
1240
  if not list_libraries_result.succeeded():
1201
1241
  details = "Attempted to get all info for all libraries, but listing the registered libraries failed."
1202
- logger.error(details)
1203
1242
  return GetAllInfoForAllLibrariesResultFailure(result_details=details)
1204
1243
 
1205
1244
  try:
@@ -1214,7 +1253,6 @@ class LibraryManager:
1214
1253
 
1215
1254
  if not library_all_info_result.succeeded():
1216
1255
  details = f"Attempted to get all info for all libraries, but failed when getting all info for library named '{library_name}'."
1217
- logger.error(details)
1218
1256
  return GetAllInfoForAllLibrariesResultFailure(result_details=details)
1219
1257
 
1220
1258
  library_all_info_success = cast("GetAllInfoForLibraryResultSuccess", library_all_info_result)
@@ -1222,7 +1260,6 @@ class LibraryManager:
1222
1260
  library_name_to_all_info[library_name] = library_all_info_success
1223
1261
  except Exception as err:
1224
1262
  details = f"Attempted to get all info for all libraries. Encountered error {err}."
1225
- logger.error(details)
1226
1263
  return GetAllInfoForAllLibrariesResultFailure(result_details=details)
1227
1264
 
1228
1265
  # We're home free now
@@ -1245,7 +1282,6 @@ class LibraryManager:
1245
1282
  LibraryRegistry.get_library(name=request.library)
1246
1283
  except KeyError:
1247
1284
  details = f"Attempted to get all library info for a Library named '{request.library}'. Failed because no Library with that name was registered."
1248
- logger.error(details)
1249
1285
  result = GetAllInfoForLibraryResultFailure(result_details=details)
1250
1286
  return result
1251
1287
 
@@ -1254,7 +1290,6 @@ class LibraryManager:
1254
1290
 
1255
1291
  if not library_metadata_result.succeeded():
1256
1292
  details = f"Attempted to get all library info for a Library named '{request.library}'. Failed attempting to get the library's metadata."
1257
- logger.error(details)
1258
1293
  return GetAllInfoForLibraryResultFailure(result_details=details)
1259
1294
 
1260
1295
  list_categories_request = ListCategoriesInLibraryRequest(library=request.library)
@@ -1262,7 +1297,6 @@ class LibraryManager:
1262
1297
 
1263
1298
  if not list_categories_result.succeeded():
1264
1299
  details = f"Attempted to get all library info for a Library named '{request.library}'. Failed attempting to get the list of categories in the library."
1265
- logger.error(details)
1266
1300
  return GetAllInfoForLibraryResultFailure(result_details=details)
1267
1301
 
1268
1302
  node_type_list_request = ListNodeTypesInLibraryRequest(library=request.library)
@@ -1270,7 +1304,6 @@ class LibraryManager:
1270
1304
 
1271
1305
  if not node_type_list_result.succeeded():
1272
1306
  details = f"Attempted to get all library info for a Library named '{request.library}'. Failed attempting to get the list of node types in the library."
1273
- logger.error(details)
1274
1307
  return GetAllInfoForLibraryResultFailure(result_details=details)
1275
1308
 
1276
1309
  # Cast everyone to their success counterparts.
@@ -1282,7 +1315,6 @@ class LibraryManager:
1282
1315
  details = (
1283
1316
  f"Attempted to get all library info for a Library named '{request.library}'. Encountered error: {err}."
1284
1317
  )
1285
- logger.error(details)
1286
1318
  return GetAllInfoForLibraryResultFailure(result_details=details)
1287
1319
 
1288
1320
  # Now build the map of node types to metadata.
@@ -1293,14 +1325,12 @@ class LibraryManager:
1293
1325
 
1294
1326
  if not node_metadata_result.succeeded():
1295
1327
  details = f"Attempted to get all library info for a Library named '{request.library}'. Failed attempting to get the metadata for a node type called '{node_type_name}'."
1296
- logger.error(details)
1297
1328
  return GetAllInfoForLibraryResultFailure(result_details=details)
1298
1329
 
1299
1330
  try:
1300
1331
  node_metadata_result_success = cast("GetNodeMetadataFromLibraryResultSuccess", node_metadata_result)
1301
1332
  except Exception as err:
1302
1333
  details = f"Attempted to get all library info for a Library named '{request.library}'. Encountered error: {err}."
1303
- logger.error(details)
1304
1334
  return GetAllInfoForLibraryResultFailure(result_details=details)
1305
1335
 
1306
1336
  # Put it into the map.
@@ -1689,13 +1719,132 @@ class LibraryManager:
1689
1719
  self.print_library_load_status()
1690
1720
 
1691
1721
  # Remove any missing libraries AFTER we've printed them for the user.
1692
- user_libraries_section = "app_events.on_app_initialization_complete.libraries_to_register"
1722
+ user_libraries_section = LIBRARIES_TO_REGISTER_KEY
1693
1723
  self._remove_missing_libraries_from_config(config_category=user_libraries_section)
1694
1724
  finally:
1695
1725
  self._libraries_loading_complete.set()
1696
1726
 
1727
+ async def _ensure_libraries_from_config(self) -> None:
1728
+ """Ensure libraries from git URLs specified in config are downloaded.
1729
+
1730
+ This method:
1731
+ 1. Reads libraries_to_download from config
1732
+ 2. Downloads any missing libraries concurrently
1733
+ 3. Logs summary of successful/failed operations
1734
+
1735
+ Supports URL format with @ref suffix (e.g., "https://github.com/user/repo@stable").
1736
+ Libraries are registered later by load_all_libraries_from_config().
1737
+ """
1738
+ config_mgr = GriptapeNodes.ConfigManager()
1739
+ git_urls = config_mgr.get_config_value(LIBRARIES_TO_DOWNLOAD_KEY, default=[])
1740
+
1741
+ if not git_urls:
1742
+ logger.debug("No libraries to download from config")
1743
+ return
1744
+
1745
+ logger.info("Starting download of %d libraries from config", len(git_urls))
1746
+
1747
+ # Use shared download method
1748
+ results = await self._download_libraries_from_git_urls(git_urls)
1749
+
1750
+ # Count successes and failures
1751
+ successful = sum(1 for r in results.values() if r["success"])
1752
+ failed = len(results) - successful
1753
+
1754
+ logger.info(
1755
+ "Completed automatic library downloads: %d successful, %d failed",
1756
+ successful,
1757
+ failed,
1758
+ )
1759
+
1760
+ async def _download_libraries_from_git_urls(
1761
+ self,
1762
+ git_urls_with_refs: list[str],
1763
+ ) -> dict[str, dict[str, Any]]:
1764
+ """Download multiple libraries from git URLs concurrently.
1765
+
1766
+ Args:
1767
+ git_urls_with_refs: List of git URLs with optional @ref suffix (e.g., "url@v1.0")
1768
+
1769
+ Returns:
1770
+ Dictionary mapping git_url_with_ref to result info:
1771
+ {
1772
+ "url@ref": {
1773
+ "success": bool,
1774
+ "library_name": str | None,
1775
+ "error": str | None,
1776
+ "skipped": bool (optional, True if already exists),
1777
+ }
1778
+ }
1779
+ """
1780
+ config_mgr = GriptapeNodes.ConfigManager()
1781
+ libraries_dir_setting = config_mgr.get_config_value("libraries_directory")
1782
+
1783
+ if not libraries_dir_setting:
1784
+ logger.warning("Cannot download libraries: libraries_directory not configured")
1785
+ return {}
1786
+
1787
+ libraries_path = config_mgr.workspace_path / libraries_dir_setting
1788
+
1789
+ async def download_one(git_url_with_ref: str) -> tuple[str, dict[str, Any]]:
1790
+ """Download a single library if not already present."""
1791
+ # Parse URL to extract git URL and optional ref
1792
+ git_url, ref = parse_git_url_with_ref(git_url_with_ref)
1793
+ target_directory_name = extract_repo_name_from_url(git_url)
1794
+ target_path = libraries_path / target_directory_name
1795
+
1796
+ # Skip if already exists
1797
+ if target_path.exists():
1798
+ logger.info("Library at '%s' already exists, skipping", target_path)
1799
+ return git_url_with_ref, {
1800
+ "success": False,
1801
+ "library_name": None,
1802
+ "error": None,
1803
+ "skipped": True,
1804
+ }
1805
+
1806
+ logger.info("Downloading library from '%s'", git_url_with_ref)
1807
+ download_result = await GriptapeNodes.ahandle_request(
1808
+ DownloadLibraryRequest(
1809
+ git_url=git_url,
1810
+ branch_tag_commit=ref,
1811
+ fail_on_exists=False,
1812
+ auto_register=False,
1813
+ )
1814
+ )
1815
+
1816
+ if isinstance(download_result, DownloadLibraryResultSuccess):
1817
+ logger.info("Downloaded library '%s'", download_result.library_name)
1818
+ return git_url_with_ref, {
1819
+ "success": True,
1820
+ "library_name": download_result.library_name,
1821
+ "error": None,
1822
+ }
1823
+
1824
+ error = str(download_result.result_details)
1825
+ logger.warning("Failed to download '%s': %s", git_url_with_ref, error)
1826
+ return git_url_with_ref, {
1827
+ "success": False,
1828
+ "library_name": None,
1829
+ "error": error,
1830
+ }
1831
+
1832
+ # Download all concurrently
1833
+ async with asyncio.TaskGroup() as tg:
1834
+ tasks = [tg.create_task(download_one(url)) for url in git_urls_with_refs]
1835
+
1836
+ # Collect results
1837
+ return dict(task.result() for task in tasks)
1838
+
1697
1839
  async def on_app_initialization_complete(self, _payload: AppInitializationComplete) -> None:
1698
- # App just got init'd. See if there are library JSONs to load!
1840
+ # Automatically migrate old XDG library paths from config
1841
+ # TODO: Remove https://github.com/griptape-ai/griptape-nodes/issues/3348
1842
+ self._migrate_old_xdg_library_paths()
1843
+
1844
+ # App just got init'd. First download any missing libraries from git URLs.
1845
+ await self._ensure_libraries_from_config()
1846
+
1847
+ # Now load all libraries from config (including newly downloaded ones)
1699
1848
  await self.load_all_libraries_from_config()
1700
1849
 
1701
1850
  # Register all secrets now that libraries are loaded and settings are merged
@@ -1774,7 +1923,7 @@ class LibraryManager:
1774
1923
  config_mgr = GriptapeNodes.ConfigManager()
1775
1924
 
1776
1925
  # Get the current libraries_to_register list
1777
- user_libraries_section = "app_events.on_app_initialization_complete.libraries_to_register"
1926
+ user_libraries_section = LIBRARIES_TO_REGISTER_KEY
1778
1927
  libraries_to_register: list[str] = config_mgr.get_config_value(user_libraries_section)
1779
1928
 
1780
1929
  # Filter out empty or whitespace-only entries
@@ -1929,6 +2078,25 @@ class LibraryManager:
1929
2078
  if issue.severity == LibraryStatus.UNUSABLE:
1930
2079
  has_disqualifying_issues = True
1931
2080
 
2081
+ # Check if library is in old XDG location
2082
+ old_xdg_libraries_path = xdg_data_home() / "griptape_nodes" / "libraries"
2083
+ library_path_obj = Path(library_file_path)
2084
+ try:
2085
+ # Check if the library path is relative to the old XDG location
2086
+ if library_path_obj.is_relative_to(old_xdg_libraries_path):
2087
+ problems.append(OldXdgLocationWarningProblem(old_path=str(library_path_obj)))
2088
+ logger.warning(
2089
+ "Library '%s' is located in old XDG data directory: %s. "
2090
+ "Starting with version 0.65.0, libraries are managed in your workspace directory. "
2091
+ "To migrate: run 'gtn init' (CLI) or go to App Settings and click 'Re-run Setup Wizard' (desktop app).",
2092
+ library_data.name,
2093
+ library_file_path,
2094
+ )
2095
+ except ValueError:
2096
+ # is_relative_to() raises ValueError if paths are on different drives
2097
+ # In this case, library is definitely not in the old XDG location
2098
+ pass
2099
+
1932
2100
  # Early exit if any version issues are disqualifying
1933
2101
  if has_disqualifying_issues:
1934
2102
  return LibraryManager.LibraryInfo(
@@ -2169,13 +2337,94 @@ class LibraryManager:
2169
2337
  ]
2170
2338
  config_mgr.set_config_value(config_category, libraries_to_register_category)
2171
2339
 
2340
+ def _migrate_old_xdg_library_paths(self) -> None:
2341
+ """Automatically removes old XDG library paths and adds git URLs to download list.
2342
+
2343
+ This method removes library paths that were stored in the old XDG data home location
2344
+ (~/.local/share/griptape_nodes/libraries/) from the libraries_to_register configuration,
2345
+ and automatically adds the corresponding git URLs to libraries_to_download to ensure
2346
+ the libraries are re-downloaded. This migration happens automatically on app startup,
2347
+ so users don't need to run gtn init.
2348
+ """
2349
+ config_mgr = GriptapeNodes.ConfigManager()
2350
+
2351
+ # Get both config lists
2352
+ register_key = LIBRARIES_TO_REGISTER_KEY
2353
+ download_key = LIBRARIES_TO_DOWNLOAD_KEY
2354
+
2355
+ libraries_to_register = config_mgr.get_config_value(register_key)
2356
+ libraries_to_download = config_mgr.get_config_value(download_key) or []
2357
+
2358
+ if not libraries_to_register:
2359
+ return
2360
+
2361
+ # Filter and get which libraries were removed
2362
+ filtered_libraries, removed_library_names = filter_old_xdg_library_paths(libraries_to_register)
2363
+
2364
+ # If any paths were removed
2365
+ paths_removed = len(libraries_to_register) - len(filtered_libraries)
2366
+ if paths_removed > 0:
2367
+ # Update libraries_to_register
2368
+ config_mgr.set_config_value(register_key, filtered_libraries)
2369
+
2370
+ # Add corresponding git URLs to libraries_to_download
2371
+ updated_downloads = self._add_git_urls_for_removed_libraries(
2372
+ libraries_to_download,
2373
+ removed_library_names,
2374
+ )
2375
+
2376
+ urls_added = len(updated_downloads) - len(libraries_to_download)
2377
+ if urls_added > 0:
2378
+ config_mgr.set_config_value(download_key, updated_downloads)
2379
+
2380
+ logger.info(
2381
+ "Automatically migrated library configuration: removed %d old XDG path(s), added %d git URL(s) to download",
2382
+ paths_removed,
2383
+ urls_added,
2384
+ )
2385
+
2386
+ def _add_git_urls_for_removed_libraries(
2387
+ self,
2388
+ current_downloads: list[str],
2389
+ removed_library_names: set[str],
2390
+ ) -> list[str]:
2391
+ """Add git URLs for removed libraries if not already present.
2392
+
2393
+ Args:
2394
+ current_downloads: Current list of git URLs in libraries_to_download
2395
+ removed_library_names: Set of library names that were removed (e.g., "griptape_nodes_library")
2396
+
2397
+ Returns:
2398
+ Updated list with new git URLs added (deduplicated)
2399
+ """
2400
+ if not removed_library_names:
2401
+ return current_downloads
2402
+
2403
+ # Get current repository names for deduplication
2404
+ current_repo_names = {extract_repo_name_from_url(url) for url in current_downloads}
2405
+
2406
+ new_downloads = current_downloads.copy()
2407
+
2408
+ for lib_name in removed_library_names:
2409
+ if lib_name not in LIBRARY_GIT_URLS:
2410
+ continue
2411
+
2412
+ git_url = LIBRARY_GIT_URLS[lib_name]
2413
+ repo_name = extract_repo_name_from_url(git_url)
2414
+
2415
+ # Only add if not already present
2416
+ if repo_name not in current_repo_names:
2417
+ new_downloads.append(git_url)
2418
+ current_repo_names.add(repo_name)
2419
+
2420
+ return new_downloads
2421
+
2172
2422
  async def reload_libraries_request(self, request: ReloadAllLibrariesRequest) -> ResultPayload: # noqa: ARG002
2173
2423
  # Start with a clean slate.
2174
2424
  clear_all_request = ClearAllObjectStateRequest(i_know_what_im_doing=True)
2175
2425
  clear_all_result = await GriptapeNodes.ahandle_request(clear_all_request)
2176
2426
  if not clear_all_result.succeeded():
2177
2427
  details = "Failed to clear the existing object state when preparing to reload all libraries."
2178
- logger.error(details)
2179
2428
  return ReloadAllLibrariesResultFailure(result_details=details)
2180
2429
 
2181
2430
  # Unload all libraries now.
@@ -2228,7 +2477,7 @@ class LibraryManager:
2228
2477
  List of library file paths found
2229
2478
  """
2230
2479
  config_mgr = GriptapeNodes.ConfigManager()
2231
- user_libraries_section = "app_events.on_app_initialization_complete.libraries_to_register"
2480
+ user_libraries_section = LIBRARIES_TO_REGISTER_KEY
2232
2481
 
2233
2482
  discovered_libraries = set()
2234
2483
 
@@ -2250,3 +2499,758 @@ class LibraryManager:
2250
2499
  process_path(library_path)
2251
2500
 
2252
2501
  return list(discovered_libraries)
2502
+
2503
+ async def check_library_update_request(self, request: CheckLibraryUpdateRequest) -> ResultPayload: # noqa: C901, PLR0911, PLR0912, PLR0915
2504
+ """Check if a library has updates available via git."""
2505
+ library_name = request.library_name
2506
+
2507
+ # Check if the library exists
2508
+ try:
2509
+ library = LibraryRegistry.get_library(name=library_name)
2510
+ except KeyError:
2511
+ details = f"Attempted to check for updates for Library '{library_name}'. Failed because no Library with that name was registered."
2512
+ return CheckLibraryUpdateResultFailure(result_details=details)
2513
+
2514
+ # Find the library file path
2515
+ library_file_path = None
2516
+ for file_path, library_info in self._library_file_path_to_info.items():
2517
+ if library_info.library_name == library_name:
2518
+ library_file_path = file_path
2519
+ break
2520
+
2521
+ if library_file_path is None:
2522
+ details = f"Attempted to check for updates for Library '{library_name}'. Failed because no file path could be found for this library."
2523
+ return CheckLibraryUpdateResultFailure(result_details=details)
2524
+
2525
+ # Get the library directory (parent of the JSON file)
2526
+ library_dir = Path(library_file_path).parent.absolute()
2527
+
2528
+ # Check if library is in a monorepo (multiple libraries in same git repository)
2529
+ if await asyncio.to_thread(is_monorepo, library_dir):
2530
+ details = (
2531
+ f"Library '{library_name}' is in a monorepo with multiple libraries. Updates must be managed manually."
2532
+ )
2533
+ logger.info(details)
2534
+ # Get git info for the response
2535
+ git_remote = await asyncio.to_thread(get_git_remote, library_dir)
2536
+ git_ref = await asyncio.to_thread(get_current_ref, library_dir)
2537
+ current_version = library.get_metadata().library_version
2538
+ return CheckLibraryUpdateResultSuccess(
2539
+ has_update=False,
2540
+ current_version=current_version,
2541
+ latest_version=current_version,
2542
+ git_remote=git_remote,
2543
+ git_ref=git_ref,
2544
+ local_commit=None,
2545
+ remote_commit=None,
2546
+ result_details=details,
2547
+ )
2548
+
2549
+ # Check if the library directory is a git repository and get remote URL and ref
2550
+ try:
2551
+ git_remote = await asyncio.to_thread(get_git_remote, library_dir)
2552
+ if git_remote is None:
2553
+ details = f"Library '{library_name}' is not a git repository or has no remote configured."
2554
+ return CheckLibraryUpdateResultFailure(result_details=details)
2555
+ except GitRemoteError as e:
2556
+ details = f"Failed to get git remote for Library '{library_name}': {e}"
2557
+ return CheckLibraryUpdateResultFailure(result_details=details)
2558
+
2559
+ try:
2560
+ git_ref = await asyncio.to_thread(get_current_ref, library_dir)
2561
+ except GitRefError as e:
2562
+ details = f"Failed to get current git reference for Library '{library_name}': {e}"
2563
+ return CheckLibraryUpdateResultFailure(result_details=details)
2564
+
2565
+ # Get current library version
2566
+ current_version = library.get_metadata().library_version
2567
+ if current_version is None:
2568
+ details = f"Library '{library_name}' has no version information."
2569
+ return CheckLibraryUpdateResultFailure(result_details=details)
2570
+
2571
+ # Get local commit SHA
2572
+ local_commit = await asyncio.to_thread(get_local_commit_sha, library_dir)
2573
+
2574
+ # Clone remote and get latest version and commit SHA (using current ref or HEAD if detached)
2575
+ try:
2576
+ ref_to_check = git_ref or "HEAD"
2577
+ version_info = await asyncio.to_thread(clone_and_get_library_version, git_remote, ref_to_check)
2578
+ latest_version = version_info.library_version
2579
+ remote_commit = version_info.commit_sha
2580
+ except GitCloneError as e:
2581
+ details = f"Failed to retrieve latest version from git remote for Library '{library_name}': {e}"
2582
+ return CheckLibraryUpdateResultFailure(result_details=details)
2583
+
2584
+ # Determine if update is available using version comparison and commit comparison
2585
+ try:
2586
+ current_ver = Version.parse(current_version)
2587
+ latest_ver = Version.parse(latest_version)
2588
+
2589
+ # Update detection logic:
2590
+ # 1. If remote version > local version -> update available (semantic versioning)
2591
+ if latest_ver > current_ver:
2592
+ has_update = True
2593
+ update_reason = "version increased"
2594
+ # 2. If remote version < local version -> no update (prevent regression)
2595
+ elif latest_ver < current_ver:
2596
+ has_update = False
2597
+ update_reason = "version decreased (regression blocked)"
2598
+ # 3. If versions equal -> check commits
2599
+ elif local_commit is not None and remote_commit is not None and local_commit != remote_commit:
2600
+ has_update = True
2601
+ update_reason = "commits differ (same version)"
2602
+ else:
2603
+ has_update = False
2604
+ update_reason = "versions and commits match"
2605
+
2606
+ except ValueError as e:
2607
+ details = f"Failed to parse version strings for Library '{library_name}': {e}"
2608
+ return CheckLibraryUpdateResultFailure(result_details=details)
2609
+
2610
+ details = f"Successfully checked for updates for Library '{library_name}'. Current version: {current_version}, Latest version: {latest_version}, Has update: {has_update} ({update_reason})"
2611
+ logger.info(details)
2612
+
2613
+ return CheckLibraryUpdateResultSuccess(
2614
+ has_update=has_update,
2615
+ current_version=current_version,
2616
+ latest_version=latest_version,
2617
+ git_remote=git_remote,
2618
+ git_ref=git_ref,
2619
+ local_commit=local_commit,
2620
+ remote_commit=remote_commit,
2621
+ result_details=details,
2622
+ )
2623
+
2624
+ async def _validate_and_prepare_library_for_git_operation(
2625
+ self,
2626
+ library_name: str,
2627
+ failure_result_class: type[ResultPayloadFailure],
2628
+ operation_description: str,
2629
+ ) -> LibraryGitOperationContext | ResultPayloadFailure:
2630
+ """Validate library exists and prepare for git operation.
2631
+
2632
+ Args:
2633
+ library_name: Name of the library to validate
2634
+ failure_result_class: Class to use for failure results (e.g., UpdateLibraryResultFailure)
2635
+ operation_description: Description of operation for error messages (e.g., "update", "switch branch/tag for")
2636
+
2637
+ Returns:
2638
+ On success: LibraryGitOperationContext with library info
2639
+ On failure: ResultPayloadFailure instance
2640
+ """
2641
+ # Check if the library exists
2642
+ try:
2643
+ library = LibraryRegistry.get_library(name=library_name)
2644
+ except KeyError:
2645
+ details = f"Attempted to {operation_description} Library '{library_name}'. Failed because no Library with that name was registered."
2646
+ return failure_result_class(result_details=details)
2647
+
2648
+ # Get current version
2649
+ old_version = library.get_metadata().library_version
2650
+ if old_version is None:
2651
+ details = f"Library '{library_name}' has no version information."
2652
+ return failure_result_class(result_details=details)
2653
+
2654
+ # Find the library file path
2655
+ library_file_path = None
2656
+ for file_path, library_info in self._library_file_path_to_info.items():
2657
+ if library_info.library_name == library_name:
2658
+ library_file_path = file_path
2659
+ break
2660
+
2661
+ if library_file_path is None:
2662
+ details = f"Attempted to {operation_description} Library '{library_name}'. Failed because no file path could be found for this library."
2663
+ return failure_result_class(result_details=details)
2664
+
2665
+ # Get the library directory (parent of the JSON file)
2666
+ library_dir = Path(library_file_path).parent.absolute()
2667
+
2668
+ return LibraryGitOperationContext(
2669
+ library=library,
2670
+ old_version=old_version,
2671
+ library_file_path=library_file_path,
2672
+ library_dir=library_dir,
2673
+ )
2674
+
2675
+ async def _reload_library_after_git_operation(
2676
+ self,
2677
+ library_name: str,
2678
+ library_file_path: str,
2679
+ *,
2680
+ failure_result_class: type[ResultPayloadFailure],
2681
+ ) -> str | ResultPayloadFailure:
2682
+ """Reload library after git operation.
2683
+
2684
+ Args:
2685
+ library_name: Name of the library to reload
2686
+ library_file_path: Path to the library JSON file
2687
+ failure_result_class: Class to use for failure results
2688
+
2689
+ Returns:
2690
+ On success: new_version (str, may be "unknown")
2691
+ On failure: ResultPayloadFailure instance
2692
+ """
2693
+ # Unload the library
2694
+ unload_result = GriptapeNodes.handle_request(UnloadLibraryFromRegistryRequest(library_name=library_name))
2695
+ if not unload_result.succeeded():
2696
+ details = f"Failed to unload Library '{library_name}' after git operation."
2697
+ return failure_result_class(result_details=details)
2698
+
2699
+ # Search for the library JSON file using flexible pattern to handle filename variations
2700
+ # (after git operations, the filename might change between griptape-nodes-library.json and griptape_nodes_library.json)
2701
+ library_dir = Path(library_file_path).parent
2702
+ actual_library_file = find_file_in_directory(library_dir, "griptape[-_]nodes[-_]library.json")
2703
+
2704
+ if actual_library_file is None:
2705
+ details = (
2706
+ f"Failed to find library JSON file in {library_dir} after git operation for Library '{library_name}'."
2707
+ )
2708
+ return failure_result_class(result_details=details)
2709
+
2710
+ # Use the found file path for reloading
2711
+ actual_library_file_path = str(actual_library_file)
2712
+
2713
+ # Reload the library from file
2714
+ reload_result = await GriptapeNodes.ahandle_request(
2715
+ RegisterLibraryFromFileRequest(file_path=actual_library_file_path)
2716
+ )
2717
+ if not isinstance(reload_result, RegisterLibraryFromFileResultSuccess):
2718
+ details = f"Failed to reload Library '{library_name}' after git operation."
2719
+ return failure_result_class(result_details=details)
2720
+
2721
+ # Get new version after reload
2722
+ try:
2723
+ updated_library = LibraryRegistry.get_library(name=library_name)
2724
+ new_version = updated_library.get_metadata().library_version
2725
+ if new_version is None:
2726
+ new_version = "unknown"
2727
+ except KeyError:
2728
+ new_version = "unknown"
2729
+
2730
+ return new_version
2731
+
2732
+ async def update_library_request(self, request: UpdateLibraryRequest) -> ResultPayload:
2733
+ """Update a library to the latest version using the appropriate git strategy.
2734
+
2735
+ Automatically detects whether the library uses branch-based or tag-based workflow:
2736
+ - Branch-based: Uses git fetch + git reset --hard (forces local to match remote)
2737
+ - Tag-based: Uses git fetch --tags --force + git checkout
2738
+ """
2739
+ library_name = request.library_name
2740
+
2741
+ # Validate library and prepare for git operation
2742
+ validation_result = await self._validate_and_prepare_library_for_git_operation(
2743
+ library_name=library_name,
2744
+ failure_result_class=UpdateLibraryResultFailure,
2745
+ operation_description="update",
2746
+ )
2747
+ if isinstance(validation_result, ResultPayloadFailure):
2748
+ return validation_result
2749
+
2750
+ old_version = validation_result.old_version
2751
+ library_file_path = validation_result.library_file_path
2752
+ library_dir = validation_result.library_dir
2753
+
2754
+ # Check if library is in a monorepo (multiple libraries in same git repository)
2755
+ if await asyncio.to_thread(is_monorepo, library_dir):
2756
+ details = f"Cannot update Library '{library_name}'. Repository contains multiple libraries and must be updated manually."
2757
+ return UpdateLibraryResultFailure(result_details=details)
2758
+
2759
+ # Perform git update (auto-detects branch vs tag workflow)
2760
+ try:
2761
+ await asyncio.to_thread(
2762
+ update_library_git,
2763
+ library_dir,
2764
+ overwrite_existing=request.overwrite_existing,
2765
+ )
2766
+ except (GitPullError, GitRepositoryError) as e:
2767
+ error_msg = str(e).lower()
2768
+
2769
+ # Check if error is retryable (uncommitted changes)
2770
+ retryable = "uncommitted changes" in error_msg or "unstaged changes" in error_msg
2771
+
2772
+ details = f"Failed to update Library '{library_name}': {e}"
2773
+ return UpdateLibraryResultFailure(result_details=details, retryable=retryable)
2774
+
2775
+ # Reload library
2776
+ reload_result = await self._reload_library_after_git_operation(
2777
+ library_name=library_name,
2778
+ library_file_path=library_file_path,
2779
+ failure_result_class=UpdateLibraryResultFailure,
2780
+ )
2781
+ if isinstance(reload_result, ResultPayloadFailure):
2782
+ return reload_result
2783
+
2784
+ new_version = reload_result
2785
+
2786
+ details = f"Successfully updated Library '{library_name}' from version {old_version} to {new_version}."
2787
+ return UpdateLibraryResultSuccess(
2788
+ old_version=old_version,
2789
+ new_version=new_version,
2790
+ result_details=details,
2791
+ )
2792
+
2793
+ async def switch_library_ref_request(self, request: SwitchLibraryRefRequest) -> ResultPayload:
2794
+ """Switch a library to a different git branch or tag."""
2795
+ library_name = request.library_name
2796
+ ref_name = request.ref_name
2797
+
2798
+ # Validate library and prepare for git operation
2799
+ validation_result = await self._validate_and_prepare_library_for_git_operation(
2800
+ library_name=library_name,
2801
+ failure_result_class=SwitchLibraryRefResultFailure,
2802
+ operation_description="switch branch/tag for",
2803
+ )
2804
+ if isinstance(validation_result, ResultPayloadFailure):
2805
+ return validation_result
2806
+
2807
+ old_version = validation_result.old_version
2808
+ library_file_path = validation_result.library_file_path
2809
+ library_dir = validation_result.library_dir
2810
+
2811
+ # Get current ref (branch or tag) before switch
2812
+ try:
2813
+ old_ref = await asyncio.to_thread(get_current_ref, library_dir)
2814
+ if old_ref is None:
2815
+ details = f"Library '{library_name}' is not on a branch/tag or is not a git repository."
2816
+ return SwitchLibraryRefResultFailure(result_details=details)
2817
+ except GitRefError as e:
2818
+ details = f"Failed to get current branch/tag for Library '{library_name}': {e}"
2819
+ return SwitchLibraryRefResultFailure(result_details=details)
2820
+
2821
+ # Perform git ref switch (branch or tag)
2822
+ try:
2823
+ await asyncio.to_thread(switch_branch_or_tag, library_dir, ref_name)
2824
+ except (GitRefError, GitRepositoryError) as e:
2825
+ details = f"Failed to switch to '{ref_name}' for Library '{library_name}': {e}"
2826
+ return SwitchLibraryRefResultFailure(result_details=details)
2827
+
2828
+ # Reload library
2829
+ reload_result = await self._reload_library_after_git_operation(
2830
+ library_name=library_name,
2831
+ library_file_path=library_file_path,
2832
+ failure_result_class=SwitchLibraryRefResultFailure,
2833
+ )
2834
+ if isinstance(reload_result, ResultPayloadFailure):
2835
+ return reload_result
2836
+
2837
+ new_version = reload_result
2838
+
2839
+ # Get new ref (branch or tag) after switch
2840
+ try:
2841
+ new_ref = await asyncio.to_thread(get_current_ref, library_dir)
2842
+ if new_ref is None:
2843
+ new_ref = "unknown"
2844
+ except GitRefError:
2845
+ new_ref = "unknown"
2846
+
2847
+ details = f"Successfully switched Library '{library_name}' from '{old_ref}' (version {old_version}) to '{new_ref}' (version {new_version})."
2848
+ return SwitchLibraryRefResultSuccess(
2849
+ old_ref=old_ref,
2850
+ new_ref=new_ref,
2851
+ old_version=old_version,
2852
+ new_version=new_version,
2853
+ result_details=details,
2854
+ )
2855
+
2856
+ async def download_library_request(self, request: DownloadLibraryRequest) -> ResultPayload: # noqa: PLR0911, PLR0912, PLR0915, C901
2857
+ """Download a library from a git repository."""
2858
+ git_url = request.git_url
2859
+ branch_tag_commit = request.branch_tag_commit
2860
+ target_directory_name = request.target_directory_name
2861
+ download_directory = request.download_directory
2862
+
2863
+ # Determine the parent directory for the download
2864
+ config_mgr = GriptapeNodes.ConfigManager()
2865
+
2866
+ if download_directory is not None:
2867
+ # Use custom download directory if provided
2868
+ libraries_path = Path(download_directory)
2869
+ else:
2870
+ # Use default from config
2871
+ libraries_dir_setting = config_mgr.get_config_value("libraries_directory")
2872
+ if not libraries_dir_setting:
2873
+ details = "Cannot download library: libraries_directory setting is not configured."
2874
+ return DownloadLibraryResultFailure(result_details=details)
2875
+ libraries_path = config_mgr.workspace_path / libraries_dir_setting
2876
+
2877
+ # Ensure parent directory exists
2878
+ libraries_path.mkdir(parents=True, exist_ok=True)
2879
+
2880
+ # Determine target directory name
2881
+ if target_directory_name is None:
2882
+ # Extract from git URL (e.g., "https://github.com/user/repo.git" -> "repo")
2883
+ target_directory_name = git_url.rstrip("/").split("/")[-1]
2884
+ target_directory_name = target_directory_name.removesuffix(".git")
2885
+
2886
+ # Construct full target path
2887
+ target_path = libraries_path / target_directory_name
2888
+
2889
+ # Check if target directory already exists
2890
+ skip_clone = False
2891
+ if target_path.exists():
2892
+ if request.overwrite_existing:
2893
+ # Delete existing directory before cloning
2894
+ delete_request = DeleteFileRequest(path=str(target_path), workspace_only=False)
2895
+ delete_result = await GriptapeNodes.ahandle_request(delete_request)
2896
+
2897
+ if isinstance(delete_result, DeleteFileResultFailure):
2898
+ details = f"Cannot delete existing directory at {target_path}: {delete_result.result_details}"
2899
+ return DownloadLibraryResultFailure(result_details=details)
2900
+
2901
+ logger.info("Deleted existing directory at %s for overwrite", target_path)
2902
+ else:
2903
+ # Check fail_on_exists flag
2904
+ if request.fail_on_exists:
2905
+ # Fail with retryable error for interactive CLI
2906
+ details = f"Cannot download library: target directory already exists at {target_path}"
2907
+ return DownloadLibraryResultFailure(result_details=details, retryable=True)
2908
+
2909
+ # Skip cloning since directory already exists, but continue with registration
2910
+ skip_clone = True
2911
+ logger.debug(
2912
+ "Library directory already exists at %s, skipping download but will proceed with registration",
2913
+ target_path,
2914
+ )
2915
+
2916
+ # Clone the repository (unless skipping because it already exists)
2917
+ if skip_clone:
2918
+ logger.debug("Using existing library directory at %s", target_path)
2919
+ else:
2920
+ try:
2921
+ await asyncio.to_thread(clone_repository, git_url, target_path, branch_tag_commit)
2922
+ except GitCloneError as e:
2923
+ details = f"Failed to clone repository from {git_url} to {target_path}: {e}"
2924
+ return DownloadLibraryResultFailure(result_details=details)
2925
+
2926
+ # Recursively search for griptape_nodes_library.json file
2927
+ library_json_path = find_file_in_directory(target_path, "griptape[-_]nodes[-_]library.json")
2928
+ if library_json_path is None:
2929
+ details = f"Downloaded library from {git_url} but no library JSON file found in {target_path}"
2930
+ return DownloadLibraryResultFailure(result_details=details)
2931
+
2932
+ try:
2933
+ with library_json_path.open() as f:
2934
+ library_data = json.load(f)
2935
+ except json.JSONDecodeError as e:
2936
+ details = f"Failed to parse griptape_nodes_library.json from downloaded library: {e}"
2937
+ return DownloadLibraryResultFailure(result_details=details)
2938
+
2939
+ # Extract library name
2940
+ library_name = library_data.get("name")
2941
+ if library_name is None:
2942
+ details = "Downloaded library has no 'name' field in griptape_nodes_library.json"
2943
+ return DownloadLibraryResultFailure(result_details=details)
2944
+
2945
+ # Automatically register the downloaded library (unless disabled for startup downloads)
2946
+ if request.auto_register:
2947
+ register_request = RegisterLibraryFromFileRequest(file_path=str(library_json_path))
2948
+ register_result = await GriptapeNodes.ahandle_request(register_request)
2949
+ if not register_result.succeeded():
2950
+ logger.warning(
2951
+ "Library '%s' was downloaded but registration failed: %s",
2952
+ library_name,
2953
+ register_result.result_details,
2954
+ )
2955
+ else:
2956
+ logger.info("Library '%s' registered successfully", library_name)
2957
+
2958
+ # Add library JSON file path to config so it's registered on future startups
2959
+ libraries_to_register = config_mgr.get_config_value(LIBRARIES_TO_REGISTER_KEY, default=[])
2960
+ library_json_str = str(library_json_path)
2961
+ if library_json_str not in libraries_to_register:
2962
+ libraries_to_register.append(library_json_str)
2963
+ config_mgr.set_config_value(LIBRARIES_TO_REGISTER_KEY, libraries_to_register)
2964
+ logger.info("Added library '%s' to config for auto-registration on startup", library_name)
2965
+
2966
+ if skip_clone:
2967
+ details = f"Library '{library_name}' already exists at {target_path} and has been registered"
2968
+ else:
2969
+ details = f"Successfully downloaded library '{library_name}' from {git_url} to {target_path}"
2970
+ return DownloadLibraryResultSuccess(
2971
+ library_name=library_name,
2972
+ library_path=str(library_json_path),
2973
+ result_details=details,
2974
+ )
2975
+
2976
+ async def install_library_dependencies_request(self, request: InstallLibraryDependenciesRequest) -> ResultPayload: # noqa: PLR0911
2977
+ """Install dependencies for a library."""
2978
+ library_file_path = request.library_file_path
2979
+
2980
+ # Load library metadata from file
2981
+ metadata_request = LoadLibraryMetadataFromFileRequest(file_path=library_file_path)
2982
+ metadata_result = self.load_library_metadata_from_file_request(metadata_request)
2983
+
2984
+ if not isinstance(metadata_result, LoadLibraryMetadataFromFileResultSuccess):
2985
+ details = f"Failed to load library metadata from {library_file_path}: {metadata_result.result_details}"
2986
+ return InstallLibraryDependenciesResultFailure(result_details=details)
2987
+
2988
+ library_data = metadata_result.library_schema
2989
+ library_name = library_data.name
2990
+ library_metadata = library_data.metadata
2991
+
2992
+ if not library_metadata.dependencies or not library_metadata.dependencies.pip_dependencies:
2993
+ details = f"Library '{library_name}' has no dependencies to install"
2994
+ logger.info(details)
2995
+ return InstallLibraryDependenciesResultSuccess(
2996
+ library_name=library_name, dependencies_installed=0, result_details=details
2997
+ )
2998
+
2999
+ pip_dependencies = library_metadata.dependencies.pip_dependencies
3000
+ pip_install_flags = library_metadata.dependencies.pip_install_flags or []
3001
+
3002
+ # Get venv path and initialize it
3003
+ venv_path = self._get_library_venv_path(library_name, library_file_path)
3004
+
3005
+ try:
3006
+ library_venv_python_path = await self._init_library_venv(venv_path)
3007
+ except RuntimeError as e:
3008
+ details = f"Failed to initialize venv for library '{library_name}': {e}"
3009
+ return InstallLibraryDependenciesResultFailure(result_details=details)
3010
+
3011
+ if not self._can_write_to_venv_location(library_venv_python_path):
3012
+ details = f"Venv location for library '{library_name}' at {venv_path} is not writable"
3013
+ logger.warning(details)
3014
+ return InstallLibraryDependenciesResultFailure(result_details=details)
3015
+
3016
+ # Check disk space
3017
+ config_manager = GriptapeNodes.ConfigManager()
3018
+ min_space_gb = config_manager.get_config_value("minimum_disk_space_gb_libraries")
3019
+ if not OSManager.check_available_disk_space(Path(venv_path), min_space_gb):
3020
+ error_msg = OSManager.format_disk_space_error(Path(venv_path))
3021
+ details = f"Insufficient disk space for dependencies (requires {min_space_gb} GB) for library '{library_name}': {error_msg}"
3022
+ return InstallLibraryDependenciesResultFailure(result_details=details)
3023
+
3024
+ # Install dependencies
3025
+ logger.info("Installing %d dependencies for library '%s'", len(pip_dependencies), library_name)
3026
+ is_debug = config_manager.get_config_value("log_level").upper() == "DEBUG"
3027
+
3028
+ try:
3029
+ await subprocess_run(
3030
+ [
3031
+ sys.executable,
3032
+ "-m",
3033
+ "uv",
3034
+ "pip",
3035
+ "install",
3036
+ *pip_dependencies,
3037
+ *pip_install_flags,
3038
+ "--python",
3039
+ str(library_venv_python_path),
3040
+ ],
3041
+ check=True,
3042
+ capture_output=not is_debug,
3043
+ text=True,
3044
+ )
3045
+ except subprocess.CalledProcessError as e:
3046
+ details = f"Failed to install dependencies for library '{library_name}': return code={e.returncode}, stderr={e.stderr}"
3047
+ return InstallLibraryDependenciesResultFailure(result_details=details)
3048
+
3049
+ details = f"Successfully installed {len(pip_dependencies)} dependencies for library '{library_name}'"
3050
+ logger.info(details)
3051
+ return InstallLibraryDependenciesResultSuccess(
3052
+ library_name=library_name, dependencies_installed=len(pip_dependencies), result_details=details
3053
+ )
3054
+
3055
+ async def sync_libraries_request(self, request: SyncLibrariesRequest) -> ResultPayload: # noqa: C901, PLR0915
3056
+ """Sync all libraries to latest versions and ensure dependencies are installed."""
3057
+ # Phase 1: Download missing libraries from both config keys
3058
+ config_mgr = GriptapeNodes.ConfigManager()
3059
+
3060
+ # Collect git URLs from both config keys
3061
+ download_config = config_mgr.get_config_value(LIBRARIES_TO_DOWNLOAD_KEY, default=[])
3062
+ register_config = config_mgr.get_config_value(LIBRARIES_TO_REGISTER_KEY, default=[])
3063
+ git_urls_from_register = [entry for entry in register_config if is_git_url(entry)]
3064
+
3065
+ # Combine and deduplicate
3066
+ all_git_urls = list(set(download_config + git_urls_from_register))
3067
+
3068
+ # Use shared download method
3069
+ update_summary = {}
3070
+ libraries_downloaded = 0
3071
+
3072
+ if all_git_urls:
3073
+ logger.info("Found %d git URLs, downloading missing libraries", len(all_git_urls))
3074
+ download_results = await self._download_libraries_from_git_urls(all_git_urls)
3075
+
3076
+ # Process results for summary
3077
+ for git_url, result in download_results.items():
3078
+ if result["success"]:
3079
+ libraries_downloaded += 1
3080
+ update_summary[result["library_name"]] = {
3081
+ "status": "downloaded",
3082
+ "git_url": git_url,
3083
+ }
3084
+ elif result.get("error"):
3085
+ logger.warning("Download failed for '%s': %s", git_url, result["error"])
3086
+
3087
+ logger.info("Downloaded %d new libraries", libraries_downloaded)
3088
+
3089
+ # Phase 2: Load libraries to ensure newly downloaded ones are registered
3090
+ logger.info("Loading libraries to register newly downloaded ones")
3091
+ load_request = LoadLibrariesRequest()
3092
+ load_result = await GriptapeNodes.ahandle_request(load_request)
3093
+
3094
+ if not isinstance(load_result, LoadLibrariesResultSuccess):
3095
+ logger.warning("Failed to load libraries after download: %s", load_result.result_details)
3096
+ # Continue anyway - we can still update previously registered libraries
3097
+
3098
+ # Phase 3: Check and update all registered libraries
3099
+ # Get all registered libraries
3100
+ list_result = await GriptapeNodes.ahandle_request(ListRegisteredLibrariesRequest())
3101
+ if not isinstance(list_result, ListRegisteredLibrariesResultSuccess):
3102
+ details = "Failed to list registered libraries for sync"
3103
+ return SyncLibrariesResultFailure(result_details=details)
3104
+
3105
+ libraries_to_check = list_result.libraries
3106
+
3107
+ logger.info("Checking %d registered libraries for updates", len(libraries_to_check))
3108
+
3109
+ # Check all libraries for updates concurrently using task group
3110
+ async def check_library_for_update(library_name: str) -> tuple[str, ResultPayload]:
3111
+ """Check a single library for updates."""
3112
+ logger.info("Checking library '%s' for updates", library_name)
3113
+ check_result = await GriptapeNodes.ahandle_request(
3114
+ CheckLibraryUpdateRequest(library_name=library_name, failure_log_level=logging.DEBUG)
3115
+ )
3116
+ return library_name, check_result
3117
+
3118
+ # Gather all check results concurrently
3119
+ check_results: dict[str, ResultPayload] = {}
3120
+ async with asyncio.TaskGroup() as tg:
3121
+ tasks = [tg.create_task(check_library_for_update(lib)) for lib in libraries_to_check]
3122
+
3123
+ # Collect results from completed tasks
3124
+ for task in tasks:
3125
+ library_name, result = task.result()
3126
+ check_results[library_name] = result
3127
+
3128
+ # Process check results and determine which libraries need updates
3129
+ libraries_checked = len(libraries_to_check)
3130
+ libraries_updated = 0
3131
+ libraries_to_update: list[LibraryUpdateInfo] = []
3132
+
3133
+ for library_name, check_result in check_results.items():
3134
+ if not isinstance(check_result, CheckLibraryUpdateResultSuccess):
3135
+ logger.warning(
3136
+ "Failed to check for updates for library '%s', skipping: %s",
3137
+ library_name,
3138
+ str(check_result.result_details),
3139
+ )
3140
+ continue
3141
+
3142
+ if not check_result.has_update:
3143
+ logger.info("Library '%s' is up to date (version %s)", library_name, check_result.current_version)
3144
+ continue
3145
+
3146
+ # Library has an update available
3147
+ old_version = check_result.current_version or "unknown"
3148
+ new_version = check_result.latest_version or "unknown"
3149
+ logger.info("Library '%s' has update available: %s -> %s", library_name, old_version, new_version)
3150
+ libraries_to_update.append(
3151
+ LibraryUpdateInfo(library_name=library_name, old_version=old_version, new_version=new_version)
3152
+ )
3153
+
3154
+ # Update libraries concurrently using task group
3155
+ async def update_library(library_name: str, old_version: str, new_version: str) -> LibraryUpdateResult:
3156
+ """Update a single library."""
3157
+ logger.info("Updating library '%s' from %s to %s", library_name, old_version, new_version)
3158
+ update_result = await GriptapeNodes.ahandle_request(
3159
+ UpdateLibraryRequest(
3160
+ library_name=library_name,
3161
+ overwrite_existing=request.overwrite_existing,
3162
+ )
3163
+ )
3164
+ return LibraryUpdateResult(
3165
+ library_name=library_name,
3166
+ old_version=old_version,
3167
+ new_version=new_version,
3168
+ result=update_result,
3169
+ )
3170
+
3171
+ # Gather all update results concurrently
3172
+ async with asyncio.TaskGroup() as tg:
3173
+ update_tasks = [
3174
+ tg.create_task(update_library(info.library_name, info.old_version, info.new_version))
3175
+ for info in libraries_to_update
3176
+ ]
3177
+
3178
+ # Collect update results
3179
+ for task in update_tasks:
3180
+ result = task.result()
3181
+ library_name = result.library_name
3182
+ old_version = result.old_version
3183
+ new_version = result.new_version
3184
+ update_result = result.result
3185
+
3186
+ if not isinstance(update_result, UpdateLibraryResultSuccess):
3187
+ logger.error("Failed to update library '%s': %s", library_name, update_result.result_details)
3188
+ update_summary[library_name] = {
3189
+ "old_version": old_version,
3190
+ "new_version": old_version,
3191
+ "status": "failed",
3192
+ "error": update_result.result_details,
3193
+ }
3194
+ continue
3195
+
3196
+ libraries_updated += 1
3197
+ update_summary[library_name] = {
3198
+ "old_version": update_result.old_version,
3199
+ "new_version": update_result.new_version,
3200
+ "status": "updated",
3201
+ }
3202
+ logger.info(
3203
+ "Successfully updated library '%s' from %s to %s",
3204
+ library_name,
3205
+ update_result.old_version,
3206
+ update_result.new_version,
3207
+ )
3208
+
3209
+ # Build result details
3210
+ details = f"Downloaded {libraries_downloaded} libraries. Checked {libraries_checked} libraries. {libraries_updated} updated."
3211
+ logger.info(details)
3212
+ return SyncLibrariesResultSuccess(
3213
+ libraries_downloaded=libraries_downloaded,
3214
+ libraries_checked=libraries_checked,
3215
+ libraries_updated=libraries_updated,
3216
+ update_summary=update_summary,
3217
+ result_details=details,
3218
+ )
3219
+
3220
+ async def inspect_library_repo_request(self, request: InspectLibraryRepoRequest) -> ResultPayload:
3221
+ """Inspect a library's metadata from a git repository without downloading the full repository."""
3222
+ git_url = request.git_url
3223
+ ref = request.ref
3224
+
3225
+ # Normalize GitHub shorthand to full URL
3226
+ from griptape_nodes.utils.git_utils import normalize_github_url, sparse_checkout_library_json
3227
+
3228
+ normalized_url = normalize_github_url(git_url)
3229
+ logger.info("Inspecting library metadata from '%s' (ref: %s)", normalized_url, ref)
3230
+
3231
+ # Perform sparse checkout to get library JSON
3232
+ try:
3233
+ library_version, commit_sha, library_data_raw = sparse_checkout_library_json(normalized_url, ref)
3234
+ except GitCloneError as e:
3235
+ details = f"Failed to inspect library from {normalized_url}: {e}"
3236
+ logger.error(details)
3237
+ return InspectLibraryRepoResultFailure(result_details=details)
3238
+
3239
+ # Validate and create LibrarySchema
3240
+ try:
3241
+ library_schema = LibrarySchema(**library_data_raw)
3242
+ except Exception as e:
3243
+ details = f"Invalid library schema from {normalized_url}: {e}"
3244
+ logger.error(details)
3245
+ return InspectLibraryRepoResultFailure(result_details=details)
3246
+
3247
+ # Return success with full library metadata
3248
+ details = f"Successfully inspected library '{library_schema.name}' (version {library_version}) from {normalized_url} at commit {commit_sha[:7]}"
3249
+ logger.info(details)
3250
+ return InspectLibraryRepoResultSuccess(
3251
+ library_schema=library_schema,
3252
+ commit_sha=commit_sha,
3253
+ git_url=normalized_url,
3254
+ ref=ref,
3255
+ result_details=details,
3256
+ )