griptape-nodes 0.64.10__py3-none-any.whl → 0.65.0__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 +77 -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 +1143 -139
  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 +1226 -0
  51. griptape_nodes/utils/library_utils.py +122 -0
  52. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/METADATA +2 -1
  53. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/RECORD +55 -47
  54. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/WHEEL +1 -1
  55. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.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
 
@@ -145,9 +193,38 @@ if TYPE_CHECKING:
145
193
  logger = logging.getLogger("griptape_nodes")
146
194
  console = Console()
147
195
 
196
+ # Directories to exclude when scanning for Python source files (in addition to any directory starting with '.')
197
+ EXCLUDED_SCAN_DIRECTORIES = frozenset({"venv", "__pycache__"})
198
+
148
199
  TRegisteredEventData = TypeVar("TRegisteredEventData")
149
200
 
150
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
+
151
228
  class LibraryManager:
152
229
  SANDBOX_LIBRARY_NAME = "Sandbox Library"
153
230
  LIBRARY_CONFIG_FILENAME = "griptape_nodes_library.json"
@@ -255,6 +332,15 @@ class LibraryManager:
255
332
  )
256
333
  event_manager.assign_manager_to_request_type(ReloadAllLibrariesRequest, self.reload_libraries_request)
257
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)
258
344
 
259
345
  event_manager.add_listener_to_app_event(
260
346
  AppInitializationComplete,
@@ -417,7 +503,6 @@ class LibraryManager:
417
503
  library = LibraryRegistry.get_library(name=request.library)
418
504
  except KeyError:
419
505
  details = f"Attempted to list node types in a Library named '{request.library}'. Failed because no Library with that name was registered."
420
- logger.error(details)
421
506
 
422
507
  result = ListNodeTypesInLibraryResultFailure(result_details=details)
423
508
  return result
@@ -440,7 +525,6 @@ class LibraryManager:
440
525
  library = LibraryRegistry.get_library(name=request.library)
441
526
  except KeyError:
442
527
  details = f"Attempted to get metadata for Library '{request.library}'. Failed because no Library with that name was registered."
443
- logger.error(details)
444
528
 
445
529
  result = GetLibraryMetadataResultFailure(result_details=details)
446
530
  return result
@@ -466,7 +550,6 @@ class LibraryManager:
466
550
  # Check if the file exists
467
551
  if not json_path.exists():
468
552
  details = f"Attempted to load Library JSON file. Failed because no file could be found at the specified path: {json_path}"
469
- logger.error(details)
470
553
  return LoadLibraryMetadataFromFileResultFailure(
471
554
  library_path=file_path,
472
555
  library_name=None,
@@ -481,7 +564,6 @@ class LibraryManager:
481
564
  library_json = json.load(f)
482
565
  except json.JSONDecodeError:
483
566
  details = f"Attempted to load Library JSON file. Failed because the file at path '{json_path}' was improperly formatted."
484
- logger.error(details)
485
567
  return LoadLibraryMetadataFromFileResultFailure(
486
568
  library_path=file_path,
487
569
  library_name=None,
@@ -491,7 +573,6 @@ class LibraryManager:
491
573
  )
492
574
  except Exception as err:
493
575
  details = f"Attempted to load Library JSON file from location '{json_path}'. Failed because an exception occurred: {err}"
494
- logger.error(details)
495
576
  return LoadLibraryMetadataFromFileResultFailure(
496
577
  library_path=file_path,
497
578
  library_name=None,
@@ -516,7 +597,6 @@ class LibraryManager:
516
597
  problem = LibrarySchemaValidationProblem(location=loc, error_type=error_type, message=msg)
517
598
  problems.append(problem)
518
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}"
519
- logger.error(details)
520
600
  return LoadLibraryMetadataFromFileResultFailure(
521
601
  library_path=file_path,
522
602
  library_name=library_name,
@@ -526,7 +606,6 @@ class LibraryManager:
526
606
  )
527
607
  except Exception as err:
528
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}"
529
- logger.error(details)
530
609
  return LoadLibraryMetadataFromFileResultFailure(
531
610
  library_path=file_path,
532
611
  library_name=library_name,
@@ -535,9 +614,18 @@ class LibraryManager:
535
614
  result_details=details,
536
615
  )
537
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
+ git_ref = get_current_ref(library_dir)
621
+
538
622
  details = f"Successfully loaded library metadata from JSON file at {json_path}"
539
623
  return LoadLibraryMetadataFromFileResultSuccess(
540
- library_schema=library_data, file_path=file_path, result_details=details
624
+ library_schema=library_data,
625
+ file_path=file_path,
626
+ git_remote=git_remote,
627
+ git_ref=git_ref,
628
+ result_details=details,
541
629
  )
542
630
 
543
631
  def load_metadata_for_all_libraries_request(self, request: LoadMetadataForAllLibrariesRequest) -> ResultPayload: # noqa: ARG002
@@ -683,9 +771,17 @@ class LibraryManager:
683
771
  nodes=node_definitions,
684
772
  )
685
773
 
774
+ # Get git remote and ref if the sandbox directory is in a git repository
775
+ git_remote = get_git_remote(sandbox_library_dir)
776
+ git_ref = get_current_ref(sandbox_library_dir)
777
+
686
778
  details = f"Successfully generated sandbox library metadata with {len(node_definitions)} nodes from {sandbox_library_dir}"
687
779
  return LoadLibraryMetadataFromFileResultSuccess(
688
- library_schema=library_schema, file_path=str(sandbox_library_dir), result_details=details
780
+ library_schema=library_schema,
781
+ file_path=str(sandbox_library_dir),
782
+ git_remote=git_remote,
783
+ git_ref=git_ref,
784
+ result_details=details,
689
785
  )
690
786
 
691
787
  def get_node_metadata_from_library_request(self, request: GetNodeMetadataFromLibraryRequest) -> ResultPayload:
@@ -719,7 +815,6 @@ class LibraryManager:
719
815
  library = LibraryRegistry.get_library(name=request.library)
720
816
  except KeyError:
721
817
  details = f"Attempted to get categories in a Library named '{request.library}'. Failed because no Library with that name was registered."
722
- logger.error(details)
723
818
  result = ListCategoriesInLibraryResultFailure(result_details=details)
724
819
  return result
725
820
 
@@ -744,7 +839,6 @@ class LibraryManager:
744
839
  problems=[LibraryNotFoundProblem(library_path=file_path)],
745
840
  )
746
841
  details = f"Attempted to load Library JSON file. Failed because no file could be found at the specified path: {json_path}"
747
- logger.error(details)
748
842
  return RegisterLibraryFromFileResultFailure(result_details=details)
749
843
 
750
844
  # Use the new metadata loading functionality
@@ -776,7 +870,6 @@ class LibraryManager:
776
870
  problems=[InvalidVersionStringProblem(version_string=str(library_data.metadata.library_version))],
777
871
  )
778
872
  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."
779
- logger.error(details)
780
873
  return RegisterLibraryFromFileResultFailure(result_details=details)
781
874
 
782
875
  # Get the directory containing the JSON file to resolve relative paths
@@ -784,6 +877,39 @@ class LibraryManager:
784
877
  # Add the directory to the Python path to allow for relative imports
785
878
  sys.path.insert(0, str(base_dir))
786
879
 
880
+ # Install dependencies and add library venv to sys.path if library has dependencies
881
+ if library_data.metadata.dependencies and library_data.metadata.dependencies.pip_dependencies:
882
+ venv_path = self._get_library_venv_path(library_data.name, file_path)
883
+
884
+ install_request = InstallLibraryDependenciesRequest(library_file_path=file_path)
885
+ install_result = await self.install_library_dependencies_request(install_request)
886
+
887
+ if isinstance(install_result, InstallLibraryDependenciesResultFailure):
888
+ details = (
889
+ f"Failed to install dependencies for library '{library_data.name}': {install_result.result_details}"
890
+ )
891
+ return RegisterLibraryFromFileResultFailure(result_details=details)
892
+
893
+ if isinstance(install_result, InstallLibraryDependenciesResultSuccess):
894
+ logger.info(
895
+ "Installed %d dependencies for library '%s'",
896
+ install_result.dependencies_installed,
897
+ library_data.name,
898
+ )
899
+
900
+ # Add venv site-packages to sys.path so node imports can find dependencies
901
+ if venv_path.exists():
902
+ site_packages = str(
903
+ Path(
904
+ sysconfig.get_path(
905
+ "purelib",
906
+ vars={"base": str(venv_path), "platbase": str(venv_path)},
907
+ )
908
+ )
909
+ )
910
+ sys.path.insert(0, site_packages)
911
+ logger.debug("Added library '%s' venv to sys.path: %s", library_data.name, site_packages)
912
+
787
913
  # Load the advanced library module if specified
788
914
  advanced_library_instance = None
789
915
  if library_data.advanced_library_path:
@@ -805,7 +931,6 @@ class LibraryManager:
805
931
  ],
806
932
  )
807
933
  details = f"Attempted to load Library '{library_data.name}' from '{json_path}'. Failed to load Advanced Library module: {err}"
808
- logger.error(details)
809
934
  return RegisterLibraryFromFileResultFailure(result_details=details)
810
935
 
811
936
  # Create or get the library
@@ -828,91 +953,6 @@ class LibraryManager:
828
953
  )
829
954
 
830
955
  details = f"Attempted to load Library JSON file from '{json_path}'. Failed because a Library '{library_data.name}' already exists. Error: {err}."
831
- logger.error(details)
832
- return RegisterLibraryFromFileResultFailure(result_details=details)
833
-
834
- # Install node library dependencies
835
- try:
836
- if library_data.metadata.dependencies and library_data.metadata.dependencies.pip_dependencies:
837
- pip_install_flags = library_data.metadata.dependencies.pip_install_flags
838
- if pip_install_flags is None:
839
- pip_install_flags = []
840
- pip_dependencies = library_data.metadata.dependencies.pip_dependencies
841
-
842
- # Determine venv path for dependency installation
843
- venv_path = self._get_library_venv_path(library_data.name, file_path)
844
-
845
- # Only install dependencies if conditions are met
846
- try:
847
- library_venv_python_path = await self._init_library_venv(venv_path)
848
- except RuntimeError as e:
849
- self._library_file_path_to_info[file_path] = LibraryManager.LibraryInfo(
850
- library_path=file_path,
851
- library_name=library_data.name,
852
- library_version=library_version,
853
- status=LibraryStatus.UNUSABLE,
854
- problems=[VenvCreationFailedProblem(error_message=str(e))],
855
- )
856
- details = f"Attempted to load Library JSON file from '{json_path}'. Failed when creating the virtual environment: {e}."
857
- logger.error(details)
858
- return RegisterLibraryFromFileResultFailure(result_details=details)
859
- if self._can_write_to_venv_location(library_venv_python_path):
860
- # Check disk space before installing dependencies
861
- config_manager = GriptapeNodes.ConfigManager()
862
- min_space_gb = config_manager.get_config_value("minimum_disk_space_gb_libraries")
863
- if not OSManager.check_available_disk_space(Path(venv_path), min_space_gb):
864
- error_msg = OSManager.format_disk_space_error(Path(venv_path))
865
- 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}"
866
- logger.error(details)
867
- self._library_file_path_to_info[file_path] = LibraryManager.LibraryInfo(
868
- library_path=file_path,
869
- library_name=library_data.name,
870
- library_version=library_version,
871
- status=LibraryStatus.UNUSABLE,
872
- problems=[InsufficientDiskSpaceProblem(min_space_gb=min_space_gb, error_message=error_msg)],
873
- )
874
- return RegisterLibraryFromFileResultFailure(result_details=details)
875
-
876
- # Grab the python executable from the virtual environment so that we can pip install there
877
- logger.info(
878
- "Installing dependencies for library '%s' with pip in venv at %s", library_data.name, venv_path
879
- )
880
- is_debug = config_manager.get_config_value("log_level").upper() == "DEBUG"
881
- await subprocess_run(
882
- [
883
- sys.executable,
884
- "-m",
885
- "uv",
886
- "pip",
887
- "install",
888
- *pip_dependencies,
889
- *pip_install_flags,
890
- "--python",
891
- str(library_venv_python_path),
892
- ],
893
- check=True,
894
- capture_output=not is_debug,
895
- text=True,
896
- )
897
- else:
898
- logger.debug(
899
- "Skipping dependency installation for library '%s' - venv location at %s is not writable",
900
- library_data.name,
901
- venv_path,
902
- )
903
- except subprocess.CalledProcessError as e:
904
- # Failed to create the library
905
- error_details = f"return code={e.returncode}, stdout={e.stdout}, stderr={e.stderr}"
906
-
907
- self._library_file_path_to_info[file_path] = LibraryManager.LibraryInfo(
908
- library_path=file_path,
909
- library_name=library_data.name,
910
- library_version=library_version,
911
- status=LibraryStatus.UNUSABLE,
912
- problems=[DependencyInstallationFailedProblem(error_details=error_details)],
913
- )
914
- details = f"Attempted to load Library JSON file from '{json_path}'. Failed when installing dependencies: {error_details}"
915
- logger.error(details)
916
956
  return RegisterLibraryFromFileResultFailure(result_details=details)
917
957
 
918
958
  # We are at least potentially viable.
@@ -924,7 +964,10 @@ class LibraryManager:
924
964
  # Assign them into the config space.
925
965
  for library_data_setting in library_data.settings:
926
966
  # Does the category exist?
927
- get_category_request = GetConfigCategoryRequest(category=library_data_setting.category)
967
+ get_category_request = GetConfigCategoryRequest(
968
+ category=library_data_setting.category,
969
+ failure_log_level=logging.DEBUG, # Missing category is expected, suppress error toast
970
+ )
928
971
  get_category_result = GriptapeNodes.handle_request(get_category_request)
929
972
  if not isinstance(get_category_result, GetConfigCategoryResultSuccess):
930
973
  # That's OK, we'll invent it. Or at least we'll try.
@@ -980,7 +1023,6 @@ class LibraryManager:
980
1023
  return RegisterLibraryFromFileResultFailure(result_details=details)
981
1024
  case _:
982
1025
  details = f"Attempted to load Library JSON file from '{json_path}'. Failed because an unknown/unexpected status '{library_load_results.status}' was returned."
983
- logger.error(details)
984
1026
  return RegisterLibraryFromFileResultFailure(result_details=details)
985
1027
 
986
1028
  async def register_library_from_requirement_specifier_request(
@@ -991,21 +1033,29 @@ class LibraryManager:
991
1033
  # Determine venv path for dependency installation
992
1034
  venv_path = self._get_library_venv_path(package_name, None)
993
1035
 
1036
+ # Check if venv already exists before initialization
1037
+ venv_already_exists = venv_path.exists()
1038
+
994
1039
  # Only install dependencies if conditions are met
995
1040
  try:
996
1041
  library_python_venv_path = await self._init_library_venv(venv_path)
997
1042
  except RuntimeError as e:
998
1043
  details = f"Attempted to install library '{request.requirement_specifier}'. Failed when creating the virtual environment: {e}"
999
- logger.error(details)
1000
1044
  return RegisterLibraryFromRequirementSpecifierResultFailure(result_details=details)
1001
- if self._can_write_to_venv_location(library_python_venv_path):
1045
+
1046
+ if venv_already_exists:
1047
+ logger.debug(
1048
+ "Skipping dependency installation for package '%s' - venv already exists at %s",
1049
+ package_name,
1050
+ venv_path,
1051
+ )
1052
+ elif self._can_write_to_venv_location(library_python_venv_path):
1002
1053
  # Check disk space before installing dependencies
1003
1054
  config_manager = GriptapeNodes.ConfigManager()
1004
1055
  min_space_gb = config_manager.get_config_value("minimum_disk_space_gb_libraries")
1005
1056
  if not OSManager.check_available_disk_space(Path(venv_path), min_space_gb):
1006
1057
  error_msg = OSManager.format_disk_space_error(Path(venv_path))
1007
1058
  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}"
1008
- logger.error(details)
1009
1059
  return RegisterLibraryFromRequirementSpecifierResultFailure(result_details=details)
1010
1060
 
1011
1061
  uv_path = find_uv_bin()
@@ -1033,11 +1083,9 @@ class LibraryManager:
1033
1083
  )
1034
1084
  except subprocess.CalledProcessError as e:
1035
1085
  details = f"Attempted to install library '{request.requirement_specifier}'. Failed: return code={e.returncode}, stdout={e.stdout}, stderr={e.stderr}"
1036
- logger.error(details)
1037
1086
  return RegisterLibraryFromRequirementSpecifierResultFailure(result_details=details)
1038
1087
  except InvalidRequirement as e:
1039
1088
  details = f"Attempted to install library '{request.requirement_specifier}'. Failed due to invalid requirement specifier: {e}"
1040
- logger.error(details)
1041
1089
  return RegisterLibraryFromRequirementSpecifierResultFailure(result_details=details)
1042
1090
 
1043
1091
  library_path = str(files(package_name).joinpath(request.library_config_name))
@@ -1045,7 +1093,6 @@ class LibraryManager:
1045
1093
  register_result = GriptapeNodes.handle_request(RegisterLibraryFromFileRequest(file_path=library_path))
1046
1094
  if isinstance(register_result, RegisterLibraryFromFileResultFailure):
1047
1095
  details = f"Attempted to install library '{request.requirement_specifier}'. Failed due to {register_result}"
1048
- logger.error(details)
1049
1096
  return RegisterLibraryFromRequirementSpecifierResultFailure(result_details=details)
1050
1097
 
1051
1098
  return RegisterLibraryFromRequirementSpecifierResultSuccess(
@@ -1107,17 +1154,6 @@ class LibraryManager:
1107
1154
  else:
1108
1155
  library_venv_python_path = library_venv_path / "bin" / "python"
1109
1156
 
1110
- # Need to insert into the path so that the library picks up on the venv
1111
- site_packages = str(
1112
- Path(
1113
- sysconfig.get_path(
1114
- "purelib",
1115
- vars={"base": str(library_venv_path), "platbase": str(library_venv_path)},
1116
- )
1117
- )
1118
- )
1119
- sys.path.insert(0, site_packages)
1120
-
1121
1157
  return library_venv_python_path
1122
1158
 
1123
1159
  def _get_library_venv_path(self, library_name: str, library_file_path: str | None = None) -> Path:
@@ -1176,7 +1212,6 @@ class LibraryManager:
1176
1212
  LibraryRegistry.unregister_library(library_name=request.library_name)
1177
1213
  except Exception as e:
1178
1214
  details = f"Attempted to unload library '{request.library_name}'. Failed due to {e}"
1179
- logger.error(details)
1180
1215
  return UnloadLibraryFromRegistryResultFailure(result_details=details)
1181
1216
 
1182
1217
  # Clean up all stable module aliases for this library
@@ -1196,7 +1231,6 @@ class LibraryManager:
1196
1231
 
1197
1232
  if not list_libraries_result.succeeded():
1198
1233
  details = "Attempted to get all info for all libraries, but listing the registered libraries failed."
1199
- logger.error(details)
1200
1234
  return GetAllInfoForAllLibrariesResultFailure(result_details=details)
1201
1235
 
1202
1236
  try:
@@ -1211,7 +1245,6 @@ class LibraryManager:
1211
1245
 
1212
1246
  if not library_all_info_result.succeeded():
1213
1247
  details = f"Attempted to get all info for all libraries, but failed when getting all info for library named '{library_name}'."
1214
- logger.error(details)
1215
1248
  return GetAllInfoForAllLibrariesResultFailure(result_details=details)
1216
1249
 
1217
1250
  library_all_info_success = cast("GetAllInfoForLibraryResultSuccess", library_all_info_result)
@@ -1219,7 +1252,6 @@ class LibraryManager:
1219
1252
  library_name_to_all_info[library_name] = library_all_info_success
1220
1253
  except Exception as err:
1221
1254
  details = f"Attempted to get all info for all libraries. Encountered error {err}."
1222
- logger.error(details)
1223
1255
  return GetAllInfoForAllLibrariesResultFailure(result_details=details)
1224
1256
 
1225
1257
  # We're home free now
@@ -1242,7 +1274,6 @@ class LibraryManager:
1242
1274
  LibraryRegistry.get_library(name=request.library)
1243
1275
  except KeyError:
1244
1276
  details = f"Attempted to get all library info for a Library named '{request.library}'. Failed because no Library with that name was registered."
1245
- logger.error(details)
1246
1277
  result = GetAllInfoForLibraryResultFailure(result_details=details)
1247
1278
  return result
1248
1279
 
@@ -1251,7 +1282,6 @@ class LibraryManager:
1251
1282
 
1252
1283
  if not library_metadata_result.succeeded():
1253
1284
  details = f"Attempted to get all library info for a Library named '{request.library}'. Failed attempting to get the library's metadata."
1254
- logger.error(details)
1255
1285
  return GetAllInfoForLibraryResultFailure(result_details=details)
1256
1286
 
1257
1287
  list_categories_request = ListCategoriesInLibraryRequest(library=request.library)
@@ -1259,7 +1289,6 @@ class LibraryManager:
1259
1289
 
1260
1290
  if not list_categories_result.succeeded():
1261
1291
  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."
1262
- logger.error(details)
1263
1292
  return GetAllInfoForLibraryResultFailure(result_details=details)
1264
1293
 
1265
1294
  node_type_list_request = ListNodeTypesInLibraryRequest(library=request.library)
@@ -1267,7 +1296,6 @@ class LibraryManager:
1267
1296
 
1268
1297
  if not node_type_list_result.succeeded():
1269
1298
  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."
1270
- logger.error(details)
1271
1299
  return GetAllInfoForLibraryResultFailure(result_details=details)
1272
1300
 
1273
1301
  # Cast everyone to their success counterparts.
@@ -1279,7 +1307,6 @@ class LibraryManager:
1279
1307
  details = (
1280
1308
  f"Attempted to get all library info for a Library named '{request.library}'. Encountered error: {err}."
1281
1309
  )
1282
- logger.error(details)
1283
1310
  return GetAllInfoForLibraryResultFailure(result_details=details)
1284
1311
 
1285
1312
  # Now build the map of node types to metadata.
@@ -1290,14 +1317,12 @@ class LibraryManager:
1290
1317
 
1291
1318
  if not node_metadata_result.succeeded():
1292
1319
  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}'."
1293
- logger.error(details)
1294
1320
  return GetAllInfoForLibraryResultFailure(result_details=details)
1295
1321
 
1296
1322
  try:
1297
1323
  node_metadata_result_success = cast("GetNodeMetadataFromLibraryResultSuccess", node_metadata_result)
1298
1324
  except Exception as err:
1299
1325
  details = f"Attempted to get all library info for a Library named '{request.library}'. Encountered error: {err}."
1300
- logger.error(details)
1301
1326
  return GetAllInfoForLibraryResultFailure(result_details=details)
1302
1327
 
1303
1328
  # Put it into the map.
@@ -1686,13 +1711,132 @@ class LibraryManager:
1686
1711
  self.print_library_load_status()
1687
1712
 
1688
1713
  # Remove any missing libraries AFTER we've printed them for the user.
1689
- user_libraries_section = "app_events.on_app_initialization_complete.libraries_to_register"
1714
+ user_libraries_section = LIBRARIES_TO_REGISTER_KEY
1690
1715
  self._remove_missing_libraries_from_config(config_category=user_libraries_section)
1691
1716
  finally:
1692
1717
  self._libraries_loading_complete.set()
1693
1718
 
1719
+ async def _ensure_libraries_from_config(self) -> None:
1720
+ """Ensure libraries from git URLs specified in config are downloaded.
1721
+
1722
+ This method:
1723
+ 1. Reads libraries_to_download from config
1724
+ 2. Downloads any missing libraries concurrently
1725
+ 3. Logs summary of successful/failed operations
1726
+
1727
+ Supports URL format with @ref suffix (e.g., "https://github.com/user/repo@stable").
1728
+ Libraries are registered later by load_all_libraries_from_config().
1729
+ """
1730
+ config_mgr = GriptapeNodes.ConfigManager()
1731
+ git_urls = config_mgr.get_config_value(LIBRARIES_TO_DOWNLOAD_KEY, default=[])
1732
+
1733
+ if not git_urls:
1734
+ logger.debug("No libraries to download from config")
1735
+ return
1736
+
1737
+ logger.info("Starting download of %d libraries from config", len(git_urls))
1738
+
1739
+ # Use shared download method
1740
+ results = await self._download_libraries_from_git_urls(git_urls)
1741
+
1742
+ # Count successes and failures
1743
+ successful = sum(1 for r in results.values() if r["success"])
1744
+ failed = len(results) - successful
1745
+
1746
+ logger.info(
1747
+ "Completed automatic library downloads: %d successful, %d failed",
1748
+ successful,
1749
+ failed,
1750
+ )
1751
+
1752
+ async def _download_libraries_from_git_urls(
1753
+ self,
1754
+ git_urls_with_refs: list[str],
1755
+ ) -> dict[str, dict[str, Any]]:
1756
+ """Download multiple libraries from git URLs concurrently.
1757
+
1758
+ Args:
1759
+ git_urls_with_refs: List of git URLs with optional @ref suffix (e.g., "url@v1.0")
1760
+
1761
+ Returns:
1762
+ Dictionary mapping git_url_with_ref to result info:
1763
+ {
1764
+ "url@ref": {
1765
+ "success": bool,
1766
+ "library_name": str | None,
1767
+ "error": str | None,
1768
+ "skipped": bool (optional, True if already exists),
1769
+ }
1770
+ }
1771
+ """
1772
+ config_mgr = GriptapeNodes.ConfigManager()
1773
+ libraries_dir_setting = config_mgr.get_config_value("libraries_directory")
1774
+
1775
+ if not libraries_dir_setting:
1776
+ logger.warning("Cannot download libraries: libraries_directory not configured")
1777
+ return {}
1778
+
1779
+ libraries_path = config_mgr.workspace_path / libraries_dir_setting
1780
+
1781
+ async def download_one(git_url_with_ref: str) -> tuple[str, dict[str, Any]]:
1782
+ """Download a single library if not already present."""
1783
+ # Parse URL to extract git URL and optional ref
1784
+ git_url, ref = parse_git_url_with_ref(git_url_with_ref)
1785
+ target_directory_name = extract_repo_name_from_url(git_url)
1786
+ target_path = libraries_path / target_directory_name
1787
+
1788
+ # Skip if already exists
1789
+ if target_path.exists():
1790
+ logger.info("Library at '%s' already exists, skipping", target_path)
1791
+ return git_url_with_ref, {
1792
+ "success": False,
1793
+ "library_name": None,
1794
+ "error": None,
1795
+ "skipped": True,
1796
+ }
1797
+
1798
+ logger.info("Downloading library from '%s'", git_url_with_ref)
1799
+ download_result = await GriptapeNodes.ahandle_request(
1800
+ DownloadLibraryRequest(
1801
+ git_url=git_url,
1802
+ branch_tag_commit=ref,
1803
+ fail_on_exists=False,
1804
+ auto_register=False,
1805
+ )
1806
+ )
1807
+
1808
+ if isinstance(download_result, DownloadLibraryResultSuccess):
1809
+ logger.info("Downloaded library '%s'", download_result.library_name)
1810
+ return git_url_with_ref, {
1811
+ "success": True,
1812
+ "library_name": download_result.library_name,
1813
+ "error": None,
1814
+ }
1815
+
1816
+ error = str(download_result.result_details)
1817
+ logger.warning("Failed to download '%s': %s", git_url_with_ref, error)
1818
+ return git_url_with_ref, {
1819
+ "success": False,
1820
+ "library_name": None,
1821
+ "error": error,
1822
+ }
1823
+
1824
+ # Download all concurrently
1825
+ async with asyncio.TaskGroup() as tg:
1826
+ tasks = [tg.create_task(download_one(url)) for url in git_urls_with_refs]
1827
+
1828
+ # Collect results
1829
+ return dict(task.result() for task in tasks)
1830
+
1694
1831
  async def on_app_initialization_complete(self, _payload: AppInitializationComplete) -> None:
1695
- # App just got init'd. See if there are library JSONs to load!
1832
+ # Automatically migrate old XDG library paths from config
1833
+ # TODO: Remove https://github.com/griptape-ai/griptape-nodes/issues/3348
1834
+ self._migrate_old_xdg_library_paths()
1835
+
1836
+ # App just got init'd. First download any missing libraries from git URLs.
1837
+ await self._ensure_libraries_from_config()
1838
+
1839
+ # Now load all libraries from config (including newly downloaded ones)
1696
1840
  await self.load_all_libraries_from_config()
1697
1841
 
1698
1842
  # Register all secrets now that libraries are loaded and settings are merged
@@ -1771,7 +1915,7 @@ class LibraryManager:
1771
1915
  config_mgr = GriptapeNodes.ConfigManager()
1772
1916
 
1773
1917
  # Get the current libraries_to_register list
1774
- user_libraries_section = "app_events.on_app_initialization_complete.libraries_to_register"
1918
+ user_libraries_section = LIBRARIES_TO_REGISTER_KEY
1775
1919
  libraries_to_register: list[str] = config_mgr.get_config_value(user_libraries_section)
1776
1920
 
1777
1921
  # Filter out empty or whitespace-only entries
@@ -1926,6 +2070,25 @@ class LibraryManager:
1926
2070
  if issue.severity == LibraryStatus.UNUSABLE:
1927
2071
  has_disqualifying_issues = True
1928
2072
 
2073
+ # Check if library is in old XDG location
2074
+ old_xdg_libraries_path = xdg_data_home() / "griptape_nodes" / "libraries"
2075
+ library_path_obj = Path(library_file_path)
2076
+ try:
2077
+ # Check if the library path is relative to the old XDG location
2078
+ if library_path_obj.is_relative_to(old_xdg_libraries_path):
2079
+ problems.append(OldXdgLocationWarningProblem(old_path=str(library_path_obj)))
2080
+ logger.warning(
2081
+ "Library '%s' is located in old XDG data directory: %s. "
2082
+ "Starting with version 0.65.0, libraries are managed in your workspace directory. "
2083
+ "To migrate: run 'gtn init' (CLI) or go to App Settings and click 'Re-run Setup Wizard' (desktop app).",
2084
+ library_data.name,
2085
+ library_file_path,
2086
+ )
2087
+ except ValueError:
2088
+ # is_relative_to() raises ValueError if paths are on different drives
2089
+ # In this case, library is definitely not in the old XDG location
2090
+ pass
2091
+
1929
2092
  # Early exit if any version issues are disqualifying
1930
2093
  if has_disqualifying_issues:
1931
2094
  return LibraryManager.LibraryInfo(
@@ -2136,8 +2299,13 @@ class LibraryManager:
2136
2299
  self._library_file_path_to_info[sandbox_library_dir_as_posix] = library_load_results
2137
2300
 
2138
2301
  def _find_files_in_dir(self, directory: Path, extension: str) -> list[Path]:
2302
+ """Find all files with given extension in directory, excluding common non-source directories."""
2139
2303
  ret_val = []
2140
- for root, _, files_found in os.walk(directory):
2304
+ for root, dirs, files_found in os.walk(directory):
2305
+ # Modify dirs in-place to skip excluded directories
2306
+ # Also skip any directory starting with '.'
2307
+ dirs[:] = [d for d in dirs if d not in EXCLUDED_SCAN_DIRECTORIES and not d.startswith(".")]
2308
+
2141
2309
  for file in files_found:
2142
2310
  if file.endswith(extension):
2143
2311
  file_path = Path(root) / file
@@ -2161,13 +2329,94 @@ class LibraryManager:
2161
2329
  ]
2162
2330
  config_mgr.set_config_value(config_category, libraries_to_register_category)
2163
2331
 
2332
+ def _migrate_old_xdg_library_paths(self) -> None:
2333
+ """Automatically removes old XDG library paths and adds git URLs to download list.
2334
+
2335
+ This method removes library paths that were stored in the old XDG data home location
2336
+ (~/.local/share/griptape_nodes/libraries/) from the libraries_to_register configuration,
2337
+ and automatically adds the corresponding git URLs to libraries_to_download to ensure
2338
+ the libraries are re-downloaded. This migration happens automatically on app startup,
2339
+ so users don't need to run gtn init.
2340
+ """
2341
+ config_mgr = GriptapeNodes.ConfigManager()
2342
+
2343
+ # Get both config lists
2344
+ register_key = LIBRARIES_TO_REGISTER_KEY
2345
+ download_key = LIBRARIES_TO_DOWNLOAD_KEY
2346
+
2347
+ libraries_to_register = config_mgr.get_config_value(register_key)
2348
+ libraries_to_download = config_mgr.get_config_value(download_key) or []
2349
+
2350
+ if not libraries_to_register:
2351
+ return
2352
+
2353
+ # Filter and get which libraries were removed
2354
+ filtered_libraries, removed_library_names = filter_old_xdg_library_paths(libraries_to_register)
2355
+
2356
+ # If any paths were removed
2357
+ paths_removed = len(libraries_to_register) - len(filtered_libraries)
2358
+ if paths_removed > 0:
2359
+ # Update libraries_to_register
2360
+ config_mgr.set_config_value(register_key, filtered_libraries)
2361
+
2362
+ # Add corresponding git URLs to libraries_to_download
2363
+ updated_downloads = self._add_git_urls_for_removed_libraries(
2364
+ libraries_to_download,
2365
+ removed_library_names,
2366
+ )
2367
+
2368
+ urls_added = len(updated_downloads) - len(libraries_to_download)
2369
+ if urls_added > 0:
2370
+ config_mgr.set_config_value(download_key, updated_downloads)
2371
+
2372
+ logger.info(
2373
+ "Automatically migrated library configuration: removed %d old XDG path(s), added %d git URL(s) to download",
2374
+ paths_removed,
2375
+ urls_added,
2376
+ )
2377
+
2378
+ def _add_git_urls_for_removed_libraries(
2379
+ self,
2380
+ current_downloads: list[str],
2381
+ removed_library_names: set[str],
2382
+ ) -> list[str]:
2383
+ """Add git URLs for removed libraries if not already present.
2384
+
2385
+ Args:
2386
+ current_downloads: Current list of git URLs in libraries_to_download
2387
+ removed_library_names: Set of library names that were removed (e.g., "griptape_nodes_library")
2388
+
2389
+ Returns:
2390
+ Updated list with new git URLs added (deduplicated)
2391
+ """
2392
+ if not removed_library_names:
2393
+ return current_downloads
2394
+
2395
+ # Get current repository names for deduplication
2396
+ current_repo_names = {extract_repo_name_from_url(url) for url in current_downloads}
2397
+
2398
+ new_downloads = current_downloads.copy()
2399
+
2400
+ for lib_name in removed_library_names:
2401
+ if lib_name not in LIBRARY_GIT_URLS:
2402
+ continue
2403
+
2404
+ git_url = LIBRARY_GIT_URLS[lib_name]
2405
+ repo_name = extract_repo_name_from_url(git_url)
2406
+
2407
+ # Only add if not already present
2408
+ if repo_name not in current_repo_names:
2409
+ new_downloads.append(git_url)
2410
+ current_repo_names.add(repo_name)
2411
+
2412
+ return new_downloads
2413
+
2164
2414
  async def reload_libraries_request(self, request: ReloadAllLibrariesRequest) -> ResultPayload: # noqa: ARG002
2165
2415
  # Start with a clean slate.
2166
2416
  clear_all_request = ClearAllObjectStateRequest(i_know_what_im_doing=True)
2167
2417
  clear_all_result = await GriptapeNodes.ahandle_request(clear_all_request)
2168
2418
  if not clear_all_result.succeeded():
2169
2419
  details = "Failed to clear the existing object state when preparing to reload all libraries."
2170
- logger.error(details)
2171
2420
  return ReloadAllLibrariesResultFailure(result_details=details)
2172
2421
 
2173
2422
  # Unload all libraries now.
@@ -2220,7 +2469,7 @@ class LibraryManager:
2220
2469
  List of library file paths found
2221
2470
  """
2222
2471
  config_mgr = GriptapeNodes.ConfigManager()
2223
- user_libraries_section = "app_events.on_app_initialization_complete.libraries_to_register"
2472
+ user_libraries_section = LIBRARIES_TO_REGISTER_KEY
2224
2473
 
2225
2474
  discovered_libraries = set()
2226
2475
 
@@ -2242,3 +2491,758 @@ class LibraryManager:
2242
2491
  process_path(library_path)
2243
2492
 
2244
2493
  return list(discovered_libraries)
2494
+
2495
+ async def check_library_update_request(self, request: CheckLibraryUpdateRequest) -> ResultPayload: # noqa: C901, PLR0911, PLR0912, PLR0915
2496
+ """Check if a library has updates available via git."""
2497
+ library_name = request.library_name
2498
+
2499
+ # Check if the library exists
2500
+ try:
2501
+ library = LibraryRegistry.get_library(name=library_name)
2502
+ except KeyError:
2503
+ details = f"Attempted to check for updates for Library '{library_name}'. Failed because no Library with that name was registered."
2504
+ return CheckLibraryUpdateResultFailure(result_details=details)
2505
+
2506
+ # Find the library file path
2507
+ library_file_path = None
2508
+ for file_path, library_info in self._library_file_path_to_info.items():
2509
+ if library_info.library_name == library_name:
2510
+ library_file_path = file_path
2511
+ break
2512
+
2513
+ if library_file_path is None:
2514
+ details = f"Attempted to check for updates for Library '{library_name}'. Failed because no file path could be found for this library."
2515
+ return CheckLibraryUpdateResultFailure(result_details=details)
2516
+
2517
+ # Get the library directory (parent of the JSON file)
2518
+ library_dir = Path(library_file_path).parent.absolute()
2519
+
2520
+ # Check if library is in a monorepo (multiple libraries in same git repository)
2521
+ if await asyncio.to_thread(is_monorepo, library_dir):
2522
+ details = (
2523
+ f"Library '{library_name}' is in a monorepo with multiple libraries. Updates must be managed manually."
2524
+ )
2525
+ logger.info(details)
2526
+ # Get git info for the response
2527
+ git_remote = await asyncio.to_thread(get_git_remote, library_dir)
2528
+ git_ref = await asyncio.to_thread(get_current_ref, library_dir)
2529
+ current_version = library.get_metadata().library_version
2530
+ return CheckLibraryUpdateResultSuccess(
2531
+ has_update=False,
2532
+ current_version=current_version,
2533
+ latest_version=current_version,
2534
+ git_remote=git_remote,
2535
+ git_ref=git_ref,
2536
+ local_commit=None,
2537
+ remote_commit=None,
2538
+ result_details=details,
2539
+ )
2540
+
2541
+ # Check if the library directory is a git repository and get remote URL and ref
2542
+ try:
2543
+ git_remote = await asyncio.to_thread(get_git_remote, library_dir)
2544
+ if git_remote is None:
2545
+ details = f"Library '{library_name}' is not a git repository or has no remote configured."
2546
+ return CheckLibraryUpdateResultFailure(result_details=details)
2547
+ except GitRemoteError as e:
2548
+ details = f"Failed to get git remote for Library '{library_name}': {e}"
2549
+ return CheckLibraryUpdateResultFailure(result_details=details)
2550
+
2551
+ try:
2552
+ git_ref = await asyncio.to_thread(get_current_ref, library_dir)
2553
+ except GitRefError as e:
2554
+ details = f"Failed to get current git reference for Library '{library_name}': {e}"
2555
+ return CheckLibraryUpdateResultFailure(result_details=details)
2556
+
2557
+ # Get current library version
2558
+ current_version = library.get_metadata().library_version
2559
+ if current_version is None:
2560
+ details = f"Library '{library_name}' has no version information."
2561
+ return CheckLibraryUpdateResultFailure(result_details=details)
2562
+
2563
+ # Get local commit SHA
2564
+ local_commit = await asyncio.to_thread(get_local_commit_sha, library_dir)
2565
+
2566
+ # Clone remote and get latest version and commit SHA (using current ref or HEAD if detached)
2567
+ try:
2568
+ ref_to_check = git_ref or "HEAD"
2569
+ version_info = await asyncio.to_thread(clone_and_get_library_version, git_remote, ref_to_check)
2570
+ latest_version = version_info.library_version
2571
+ remote_commit = version_info.commit_sha
2572
+ except GitCloneError as e:
2573
+ details = f"Failed to retrieve latest version from git remote for Library '{library_name}': {e}"
2574
+ return CheckLibraryUpdateResultFailure(result_details=details)
2575
+
2576
+ # Determine if update is available using version comparison and commit comparison
2577
+ try:
2578
+ current_ver = Version.parse(current_version)
2579
+ latest_ver = Version.parse(latest_version)
2580
+
2581
+ # Update detection logic:
2582
+ # 1. If remote version > local version -> update available (semantic versioning)
2583
+ if latest_ver > current_ver:
2584
+ has_update = True
2585
+ update_reason = "version increased"
2586
+ # 2. If remote version < local version -> no update (prevent regression)
2587
+ elif latest_ver < current_ver:
2588
+ has_update = False
2589
+ update_reason = "version decreased (regression blocked)"
2590
+ # 3. If versions equal -> check commits
2591
+ elif local_commit is not None and remote_commit is not None and local_commit != remote_commit:
2592
+ has_update = True
2593
+ update_reason = "commits differ (same version)"
2594
+ else:
2595
+ has_update = False
2596
+ update_reason = "versions and commits match"
2597
+
2598
+ except ValueError as e:
2599
+ details = f"Failed to parse version strings for Library '{library_name}': {e}"
2600
+ return CheckLibraryUpdateResultFailure(result_details=details)
2601
+
2602
+ details = f"Successfully checked for updates for Library '{library_name}'. Current version: {current_version}, Latest version: {latest_version}, Has update: {has_update} ({update_reason})"
2603
+ logger.info(details)
2604
+
2605
+ return CheckLibraryUpdateResultSuccess(
2606
+ has_update=has_update,
2607
+ current_version=current_version,
2608
+ latest_version=latest_version,
2609
+ git_remote=git_remote,
2610
+ git_ref=git_ref,
2611
+ local_commit=local_commit,
2612
+ remote_commit=remote_commit,
2613
+ result_details=details,
2614
+ )
2615
+
2616
+ async def _validate_and_prepare_library_for_git_operation(
2617
+ self,
2618
+ library_name: str,
2619
+ failure_result_class: type[ResultPayloadFailure],
2620
+ operation_description: str,
2621
+ ) -> LibraryGitOperationContext | ResultPayloadFailure:
2622
+ """Validate library exists and prepare for git operation.
2623
+
2624
+ Args:
2625
+ library_name: Name of the library to validate
2626
+ failure_result_class: Class to use for failure results (e.g., UpdateLibraryResultFailure)
2627
+ operation_description: Description of operation for error messages (e.g., "update", "switch branch/tag for")
2628
+
2629
+ Returns:
2630
+ On success: LibraryGitOperationContext with library info
2631
+ On failure: ResultPayloadFailure instance
2632
+ """
2633
+ # Check if the library exists
2634
+ try:
2635
+ library = LibraryRegistry.get_library(name=library_name)
2636
+ except KeyError:
2637
+ details = f"Attempted to {operation_description} Library '{library_name}'. Failed because no Library with that name was registered."
2638
+ return failure_result_class(result_details=details)
2639
+
2640
+ # Get current version
2641
+ old_version = library.get_metadata().library_version
2642
+ if old_version is None:
2643
+ details = f"Library '{library_name}' has no version information."
2644
+ return failure_result_class(result_details=details)
2645
+
2646
+ # Find the library file path
2647
+ library_file_path = None
2648
+ for file_path, library_info in self._library_file_path_to_info.items():
2649
+ if library_info.library_name == library_name:
2650
+ library_file_path = file_path
2651
+ break
2652
+
2653
+ if library_file_path is None:
2654
+ details = f"Attempted to {operation_description} Library '{library_name}'. Failed because no file path could be found for this library."
2655
+ return failure_result_class(result_details=details)
2656
+
2657
+ # Get the library directory (parent of the JSON file)
2658
+ library_dir = Path(library_file_path).parent.absolute()
2659
+
2660
+ return LibraryGitOperationContext(
2661
+ library=library,
2662
+ old_version=old_version,
2663
+ library_file_path=library_file_path,
2664
+ library_dir=library_dir,
2665
+ )
2666
+
2667
+ async def _reload_library_after_git_operation(
2668
+ self,
2669
+ library_name: str,
2670
+ library_file_path: str,
2671
+ *,
2672
+ failure_result_class: type[ResultPayloadFailure],
2673
+ ) -> str | ResultPayloadFailure:
2674
+ """Reload library after git operation.
2675
+
2676
+ Args:
2677
+ library_name: Name of the library to reload
2678
+ library_file_path: Path to the library JSON file
2679
+ failure_result_class: Class to use for failure results
2680
+
2681
+ Returns:
2682
+ On success: new_version (str, may be "unknown")
2683
+ On failure: ResultPayloadFailure instance
2684
+ """
2685
+ # Unload the library
2686
+ unload_result = GriptapeNodes.handle_request(UnloadLibraryFromRegistryRequest(library_name=library_name))
2687
+ if not unload_result.succeeded():
2688
+ details = f"Failed to unload Library '{library_name}' after git operation."
2689
+ return failure_result_class(result_details=details)
2690
+
2691
+ # Search for the library JSON file using flexible pattern to handle filename variations
2692
+ # (after git operations, the filename might change between griptape-nodes-library.json and griptape_nodes_library.json)
2693
+ library_dir = Path(library_file_path).parent
2694
+ actual_library_file = find_file_in_directory(library_dir, "griptape[-_]nodes[-_]library.json")
2695
+
2696
+ if actual_library_file is None:
2697
+ details = (
2698
+ f"Failed to find library JSON file in {library_dir} after git operation for Library '{library_name}'."
2699
+ )
2700
+ return failure_result_class(result_details=details)
2701
+
2702
+ # Use the found file path for reloading
2703
+ actual_library_file_path = str(actual_library_file)
2704
+
2705
+ # Reload the library from file
2706
+ reload_result = await GriptapeNodes.ahandle_request(
2707
+ RegisterLibraryFromFileRequest(file_path=actual_library_file_path)
2708
+ )
2709
+ if not isinstance(reload_result, RegisterLibraryFromFileResultSuccess):
2710
+ details = f"Failed to reload Library '{library_name}' after git operation."
2711
+ return failure_result_class(result_details=details)
2712
+
2713
+ # Get new version after reload
2714
+ try:
2715
+ updated_library = LibraryRegistry.get_library(name=library_name)
2716
+ new_version = updated_library.get_metadata().library_version
2717
+ if new_version is None:
2718
+ new_version = "unknown"
2719
+ except KeyError:
2720
+ new_version = "unknown"
2721
+
2722
+ return new_version
2723
+
2724
+ async def update_library_request(self, request: UpdateLibraryRequest) -> ResultPayload:
2725
+ """Update a library to the latest version using the appropriate git strategy.
2726
+
2727
+ Automatically detects whether the library uses branch-based or tag-based workflow:
2728
+ - Branch-based: Uses git fetch + git reset --hard (forces local to match remote)
2729
+ - Tag-based: Uses git fetch --tags --force + git checkout
2730
+ """
2731
+ library_name = request.library_name
2732
+
2733
+ # Validate library and prepare for git operation
2734
+ validation_result = await self._validate_and_prepare_library_for_git_operation(
2735
+ library_name=library_name,
2736
+ failure_result_class=UpdateLibraryResultFailure,
2737
+ operation_description="update",
2738
+ )
2739
+ if isinstance(validation_result, ResultPayloadFailure):
2740
+ return validation_result
2741
+
2742
+ old_version = validation_result.old_version
2743
+ library_file_path = validation_result.library_file_path
2744
+ library_dir = validation_result.library_dir
2745
+
2746
+ # Check if library is in a monorepo (multiple libraries in same git repository)
2747
+ if await asyncio.to_thread(is_monorepo, library_dir):
2748
+ details = f"Cannot update Library '{library_name}'. Repository contains multiple libraries and must be updated manually."
2749
+ return UpdateLibraryResultFailure(result_details=details)
2750
+
2751
+ # Perform git update (auto-detects branch vs tag workflow)
2752
+ try:
2753
+ await asyncio.to_thread(
2754
+ update_library_git,
2755
+ library_dir,
2756
+ overwrite_existing=request.overwrite_existing,
2757
+ )
2758
+ except (GitPullError, GitRepositoryError) as e:
2759
+ error_msg = str(e).lower()
2760
+
2761
+ # Check if error is retryable (uncommitted changes)
2762
+ retryable = "uncommitted changes" in error_msg or "unstaged changes" in error_msg
2763
+
2764
+ details = f"Failed to update Library '{library_name}': {e}"
2765
+ return UpdateLibraryResultFailure(result_details=details, retryable=retryable)
2766
+
2767
+ # Reload library
2768
+ reload_result = await self._reload_library_after_git_operation(
2769
+ library_name=library_name,
2770
+ library_file_path=library_file_path,
2771
+ failure_result_class=UpdateLibraryResultFailure,
2772
+ )
2773
+ if isinstance(reload_result, ResultPayloadFailure):
2774
+ return reload_result
2775
+
2776
+ new_version = reload_result
2777
+
2778
+ details = f"Successfully updated Library '{library_name}' from version {old_version} to {new_version}."
2779
+ return UpdateLibraryResultSuccess(
2780
+ old_version=old_version,
2781
+ new_version=new_version,
2782
+ result_details=details,
2783
+ )
2784
+
2785
+ async def switch_library_ref_request(self, request: SwitchLibraryRefRequest) -> ResultPayload:
2786
+ """Switch a library to a different git branch or tag."""
2787
+ library_name = request.library_name
2788
+ ref_name = request.ref_name
2789
+
2790
+ # Validate library and prepare for git operation
2791
+ validation_result = await self._validate_and_prepare_library_for_git_operation(
2792
+ library_name=library_name,
2793
+ failure_result_class=SwitchLibraryRefResultFailure,
2794
+ operation_description="switch branch/tag for",
2795
+ )
2796
+ if isinstance(validation_result, ResultPayloadFailure):
2797
+ return validation_result
2798
+
2799
+ old_version = validation_result.old_version
2800
+ library_file_path = validation_result.library_file_path
2801
+ library_dir = validation_result.library_dir
2802
+
2803
+ # Get current ref (branch or tag) before switch
2804
+ try:
2805
+ old_ref = await asyncio.to_thread(get_current_ref, library_dir)
2806
+ if old_ref is None:
2807
+ details = f"Library '{library_name}' is not on a branch/tag or is not a git repository."
2808
+ return SwitchLibraryRefResultFailure(result_details=details)
2809
+ except GitRefError as e:
2810
+ details = f"Failed to get current branch/tag for Library '{library_name}': {e}"
2811
+ return SwitchLibraryRefResultFailure(result_details=details)
2812
+
2813
+ # Perform git ref switch (branch or tag)
2814
+ try:
2815
+ await asyncio.to_thread(switch_branch_or_tag, library_dir, ref_name)
2816
+ except (GitRefError, GitRepositoryError) as e:
2817
+ details = f"Failed to switch to '{ref_name}' for Library '{library_name}': {e}"
2818
+ return SwitchLibraryRefResultFailure(result_details=details)
2819
+
2820
+ # Reload library
2821
+ reload_result = await self._reload_library_after_git_operation(
2822
+ library_name=library_name,
2823
+ library_file_path=library_file_path,
2824
+ failure_result_class=SwitchLibraryRefResultFailure,
2825
+ )
2826
+ if isinstance(reload_result, ResultPayloadFailure):
2827
+ return reload_result
2828
+
2829
+ new_version = reload_result
2830
+
2831
+ # Get new ref (branch or tag) after switch
2832
+ try:
2833
+ new_ref = await asyncio.to_thread(get_current_ref, library_dir)
2834
+ if new_ref is None:
2835
+ new_ref = "unknown"
2836
+ except GitRefError:
2837
+ new_ref = "unknown"
2838
+
2839
+ details = f"Successfully switched Library '{library_name}' from '{old_ref}' (version {old_version}) to '{new_ref}' (version {new_version})."
2840
+ return SwitchLibraryRefResultSuccess(
2841
+ old_ref=old_ref,
2842
+ new_ref=new_ref,
2843
+ old_version=old_version,
2844
+ new_version=new_version,
2845
+ result_details=details,
2846
+ )
2847
+
2848
+ async def download_library_request(self, request: DownloadLibraryRequest) -> ResultPayload: # noqa: PLR0911, PLR0912, PLR0915, C901
2849
+ """Download a library from a git repository."""
2850
+ git_url = request.git_url
2851
+ branch_tag_commit = request.branch_tag_commit
2852
+ target_directory_name = request.target_directory_name
2853
+ download_directory = request.download_directory
2854
+
2855
+ # Determine the parent directory for the download
2856
+ config_mgr = GriptapeNodes.ConfigManager()
2857
+
2858
+ if download_directory is not None:
2859
+ # Use custom download directory if provided
2860
+ libraries_path = Path(download_directory)
2861
+ else:
2862
+ # Use default from config
2863
+ libraries_dir_setting = config_mgr.get_config_value("libraries_directory")
2864
+ if not libraries_dir_setting:
2865
+ details = "Cannot download library: libraries_directory setting is not configured."
2866
+ return DownloadLibraryResultFailure(result_details=details)
2867
+ libraries_path = config_mgr.workspace_path / libraries_dir_setting
2868
+
2869
+ # Ensure parent directory exists
2870
+ libraries_path.mkdir(parents=True, exist_ok=True)
2871
+
2872
+ # Determine target directory name
2873
+ if target_directory_name is None:
2874
+ # Extract from git URL (e.g., "https://github.com/user/repo.git" -> "repo")
2875
+ target_directory_name = git_url.rstrip("/").split("/")[-1]
2876
+ target_directory_name = target_directory_name.removesuffix(".git")
2877
+
2878
+ # Construct full target path
2879
+ target_path = libraries_path / target_directory_name
2880
+
2881
+ # Check if target directory already exists
2882
+ skip_clone = False
2883
+ if target_path.exists():
2884
+ if request.overwrite_existing:
2885
+ # Delete existing directory before cloning
2886
+ delete_request = DeleteFileRequest(path=str(target_path), workspace_only=False)
2887
+ delete_result = await GriptapeNodes.ahandle_request(delete_request)
2888
+
2889
+ if isinstance(delete_result, DeleteFileResultFailure):
2890
+ details = f"Cannot delete existing directory at {target_path}: {delete_result.result_details}"
2891
+ return DownloadLibraryResultFailure(result_details=details)
2892
+
2893
+ logger.info("Deleted existing directory at %s for overwrite", target_path)
2894
+ else:
2895
+ # Check fail_on_exists flag
2896
+ if request.fail_on_exists:
2897
+ # Fail with retryable error for interactive CLI
2898
+ details = f"Cannot download library: target directory already exists at {target_path}"
2899
+ return DownloadLibraryResultFailure(result_details=details, retryable=True)
2900
+
2901
+ # Skip cloning since directory already exists, but continue with registration
2902
+ skip_clone = True
2903
+ logger.debug(
2904
+ "Library directory already exists at %s, skipping download but will proceed with registration",
2905
+ target_path,
2906
+ )
2907
+
2908
+ # Clone the repository (unless skipping because it already exists)
2909
+ if skip_clone:
2910
+ logger.debug("Using existing library directory at %s", target_path)
2911
+ else:
2912
+ try:
2913
+ await asyncio.to_thread(clone_repository, git_url, target_path, branch_tag_commit)
2914
+ except GitCloneError as e:
2915
+ details = f"Failed to clone repository from {git_url} to {target_path}: {e}"
2916
+ return DownloadLibraryResultFailure(result_details=details)
2917
+
2918
+ # Recursively search for griptape_nodes_library.json file
2919
+ library_json_path = find_file_in_directory(target_path, "griptape[-_]nodes[-_]library.json")
2920
+ if library_json_path is None:
2921
+ details = f"Downloaded library from {git_url} but no library JSON file found in {target_path}"
2922
+ return DownloadLibraryResultFailure(result_details=details)
2923
+
2924
+ try:
2925
+ with library_json_path.open() as f:
2926
+ library_data = json.load(f)
2927
+ except json.JSONDecodeError as e:
2928
+ details = f"Failed to parse griptape_nodes_library.json from downloaded library: {e}"
2929
+ return DownloadLibraryResultFailure(result_details=details)
2930
+
2931
+ # Extract library name
2932
+ library_name = library_data.get("name")
2933
+ if library_name is None:
2934
+ details = "Downloaded library has no 'name' field in griptape_nodes_library.json"
2935
+ return DownloadLibraryResultFailure(result_details=details)
2936
+
2937
+ # Automatically register the downloaded library (unless disabled for startup downloads)
2938
+ if request.auto_register:
2939
+ register_request = RegisterLibraryFromFileRequest(file_path=str(library_json_path))
2940
+ register_result = await GriptapeNodes.ahandle_request(register_request)
2941
+ if not register_result.succeeded():
2942
+ logger.warning(
2943
+ "Library '%s' was downloaded but registration failed: %s",
2944
+ library_name,
2945
+ register_result.result_details,
2946
+ )
2947
+ else:
2948
+ logger.info("Library '%s' registered successfully", library_name)
2949
+
2950
+ # Add library JSON file path to config so it's registered on future startups
2951
+ libraries_to_register = config_mgr.get_config_value(LIBRARIES_TO_REGISTER_KEY, default=[])
2952
+ library_json_str = str(library_json_path)
2953
+ if library_json_str not in libraries_to_register:
2954
+ libraries_to_register.append(library_json_str)
2955
+ config_mgr.set_config_value(LIBRARIES_TO_REGISTER_KEY, libraries_to_register)
2956
+ logger.info("Added library '%s' to config for auto-registration on startup", library_name)
2957
+
2958
+ if skip_clone:
2959
+ details = f"Library '{library_name}' already exists at {target_path} and has been registered"
2960
+ else:
2961
+ details = f"Successfully downloaded library '{library_name}' from {git_url} to {target_path}"
2962
+ return DownloadLibraryResultSuccess(
2963
+ library_name=library_name,
2964
+ library_path=str(library_json_path),
2965
+ result_details=details,
2966
+ )
2967
+
2968
+ async def install_library_dependencies_request(self, request: InstallLibraryDependenciesRequest) -> ResultPayload: # noqa: PLR0911
2969
+ """Install dependencies for a library."""
2970
+ library_file_path = request.library_file_path
2971
+
2972
+ # Load library metadata from file
2973
+ metadata_request = LoadLibraryMetadataFromFileRequest(file_path=library_file_path)
2974
+ metadata_result = self.load_library_metadata_from_file_request(metadata_request)
2975
+
2976
+ if not isinstance(metadata_result, LoadLibraryMetadataFromFileResultSuccess):
2977
+ details = f"Failed to load library metadata from {library_file_path}: {metadata_result.result_details}"
2978
+ return InstallLibraryDependenciesResultFailure(result_details=details)
2979
+
2980
+ library_data = metadata_result.library_schema
2981
+ library_name = library_data.name
2982
+ library_metadata = library_data.metadata
2983
+
2984
+ if not library_metadata.dependencies or not library_metadata.dependencies.pip_dependencies:
2985
+ details = f"Library '{library_name}' has no dependencies to install"
2986
+ logger.info(details)
2987
+ return InstallLibraryDependenciesResultSuccess(
2988
+ library_name=library_name, dependencies_installed=0, result_details=details
2989
+ )
2990
+
2991
+ pip_dependencies = library_metadata.dependencies.pip_dependencies
2992
+ pip_install_flags = library_metadata.dependencies.pip_install_flags or []
2993
+
2994
+ # Get venv path and initialize it
2995
+ venv_path = self._get_library_venv_path(library_name, library_file_path)
2996
+
2997
+ try:
2998
+ library_venv_python_path = await self._init_library_venv(venv_path)
2999
+ except RuntimeError as e:
3000
+ details = f"Failed to initialize venv for library '{library_name}': {e}"
3001
+ return InstallLibraryDependenciesResultFailure(result_details=details)
3002
+
3003
+ if not self._can_write_to_venv_location(library_venv_python_path):
3004
+ details = f"Venv location for library '{library_name}' at {venv_path} is not writable"
3005
+ logger.warning(details)
3006
+ return InstallLibraryDependenciesResultFailure(result_details=details)
3007
+
3008
+ # Check disk space
3009
+ config_manager = GriptapeNodes.ConfigManager()
3010
+ min_space_gb = config_manager.get_config_value("minimum_disk_space_gb_libraries")
3011
+ if not OSManager.check_available_disk_space(Path(venv_path), min_space_gb):
3012
+ error_msg = OSManager.format_disk_space_error(Path(venv_path))
3013
+ details = f"Insufficient disk space for dependencies (requires {min_space_gb} GB) for library '{library_name}': {error_msg}"
3014
+ return InstallLibraryDependenciesResultFailure(result_details=details)
3015
+
3016
+ # Install dependencies
3017
+ logger.info("Installing %d dependencies for library '%s'", len(pip_dependencies), library_name)
3018
+ is_debug = config_manager.get_config_value("log_level").upper() == "DEBUG"
3019
+
3020
+ try:
3021
+ await subprocess_run(
3022
+ [
3023
+ sys.executable,
3024
+ "-m",
3025
+ "uv",
3026
+ "pip",
3027
+ "install",
3028
+ *pip_dependencies,
3029
+ *pip_install_flags,
3030
+ "--python",
3031
+ str(library_venv_python_path),
3032
+ ],
3033
+ check=True,
3034
+ capture_output=not is_debug,
3035
+ text=True,
3036
+ )
3037
+ except subprocess.CalledProcessError as e:
3038
+ details = f"Failed to install dependencies for library '{library_name}': return code={e.returncode}, stderr={e.stderr}"
3039
+ return InstallLibraryDependenciesResultFailure(result_details=details)
3040
+
3041
+ details = f"Successfully installed {len(pip_dependencies)} dependencies for library '{library_name}'"
3042
+ logger.info(details)
3043
+ return InstallLibraryDependenciesResultSuccess(
3044
+ library_name=library_name, dependencies_installed=len(pip_dependencies), result_details=details
3045
+ )
3046
+
3047
+ async def sync_libraries_request(self, request: SyncLibrariesRequest) -> ResultPayload: # noqa: C901, PLR0915
3048
+ """Sync all libraries to latest versions and ensure dependencies are installed."""
3049
+ # Phase 1: Download missing libraries from both config keys
3050
+ config_mgr = GriptapeNodes.ConfigManager()
3051
+
3052
+ # Collect git URLs from both config keys
3053
+ download_config = config_mgr.get_config_value(LIBRARIES_TO_DOWNLOAD_KEY, default=[])
3054
+ register_config = config_mgr.get_config_value(LIBRARIES_TO_REGISTER_KEY, default=[])
3055
+ git_urls_from_register = [entry for entry in register_config if is_git_url(entry)]
3056
+
3057
+ # Combine and deduplicate
3058
+ all_git_urls = list(set(download_config + git_urls_from_register))
3059
+
3060
+ # Use shared download method
3061
+ update_summary = {}
3062
+ libraries_downloaded = 0
3063
+
3064
+ if all_git_urls:
3065
+ logger.info("Found %d git URLs, downloading missing libraries", len(all_git_urls))
3066
+ download_results = await self._download_libraries_from_git_urls(all_git_urls)
3067
+
3068
+ # Process results for summary
3069
+ for git_url, result in download_results.items():
3070
+ if result["success"]:
3071
+ libraries_downloaded += 1
3072
+ update_summary[result["library_name"]] = {
3073
+ "status": "downloaded",
3074
+ "git_url": git_url,
3075
+ }
3076
+ elif result.get("error"):
3077
+ logger.warning("Download failed for '%s': %s", git_url, result["error"])
3078
+
3079
+ logger.info("Downloaded %d new libraries", libraries_downloaded)
3080
+
3081
+ # Phase 2: Load libraries to ensure newly downloaded ones are registered
3082
+ logger.info("Loading libraries to register newly downloaded ones")
3083
+ load_request = LoadLibrariesRequest()
3084
+ load_result = await GriptapeNodes.ahandle_request(load_request)
3085
+
3086
+ if not isinstance(load_result, LoadLibrariesResultSuccess):
3087
+ logger.warning("Failed to load libraries after download: %s", load_result.result_details)
3088
+ # Continue anyway - we can still update previously registered libraries
3089
+
3090
+ # Phase 3: Check and update all registered libraries
3091
+ # Get all registered libraries
3092
+ list_result = await GriptapeNodes.ahandle_request(ListRegisteredLibrariesRequest())
3093
+ if not isinstance(list_result, ListRegisteredLibrariesResultSuccess):
3094
+ details = "Failed to list registered libraries for sync"
3095
+ return SyncLibrariesResultFailure(result_details=details)
3096
+
3097
+ libraries_to_check = list_result.libraries
3098
+
3099
+ logger.info("Checking %d registered libraries for updates", len(libraries_to_check))
3100
+
3101
+ # Check all libraries for updates concurrently using task group
3102
+ async def check_library_for_update(library_name: str) -> tuple[str, ResultPayload]:
3103
+ """Check a single library for updates."""
3104
+ logger.info("Checking library '%s' for updates", library_name)
3105
+ check_result = await GriptapeNodes.ahandle_request(
3106
+ CheckLibraryUpdateRequest(library_name=library_name, failure_log_level=logging.DEBUG)
3107
+ )
3108
+ return library_name, check_result
3109
+
3110
+ # Gather all check results concurrently
3111
+ check_results: dict[str, ResultPayload] = {}
3112
+ async with asyncio.TaskGroup() as tg:
3113
+ tasks = [tg.create_task(check_library_for_update(lib)) for lib in libraries_to_check]
3114
+
3115
+ # Collect results from completed tasks
3116
+ for task in tasks:
3117
+ library_name, result = task.result()
3118
+ check_results[library_name] = result
3119
+
3120
+ # Process check results and determine which libraries need updates
3121
+ libraries_checked = len(libraries_to_check)
3122
+ libraries_updated = 0
3123
+ libraries_to_update: list[LibraryUpdateInfo] = []
3124
+
3125
+ for library_name, check_result in check_results.items():
3126
+ if not isinstance(check_result, CheckLibraryUpdateResultSuccess):
3127
+ logger.warning(
3128
+ "Failed to check for updates for library '%s', skipping: %s",
3129
+ library_name,
3130
+ str(check_result.result_details),
3131
+ )
3132
+ continue
3133
+
3134
+ if not check_result.has_update:
3135
+ logger.info("Library '%s' is up to date (version %s)", library_name, check_result.current_version)
3136
+ continue
3137
+
3138
+ # Library has an update available
3139
+ old_version = check_result.current_version or "unknown"
3140
+ new_version = check_result.latest_version or "unknown"
3141
+ logger.info("Library '%s' has update available: %s -> %s", library_name, old_version, new_version)
3142
+ libraries_to_update.append(
3143
+ LibraryUpdateInfo(library_name=library_name, old_version=old_version, new_version=new_version)
3144
+ )
3145
+
3146
+ # Update libraries concurrently using task group
3147
+ async def update_library(library_name: str, old_version: str, new_version: str) -> LibraryUpdateResult:
3148
+ """Update a single library."""
3149
+ logger.info("Updating library '%s' from %s to %s", library_name, old_version, new_version)
3150
+ update_result = await GriptapeNodes.ahandle_request(
3151
+ UpdateLibraryRequest(
3152
+ library_name=library_name,
3153
+ overwrite_existing=request.overwrite_existing,
3154
+ )
3155
+ )
3156
+ return LibraryUpdateResult(
3157
+ library_name=library_name,
3158
+ old_version=old_version,
3159
+ new_version=new_version,
3160
+ result=update_result,
3161
+ )
3162
+
3163
+ # Gather all update results concurrently
3164
+ async with asyncio.TaskGroup() as tg:
3165
+ update_tasks = [
3166
+ tg.create_task(update_library(info.library_name, info.old_version, info.new_version))
3167
+ for info in libraries_to_update
3168
+ ]
3169
+
3170
+ # Collect update results
3171
+ for task in update_tasks:
3172
+ result = task.result()
3173
+ library_name = result.library_name
3174
+ old_version = result.old_version
3175
+ new_version = result.new_version
3176
+ update_result = result.result
3177
+
3178
+ if not isinstance(update_result, UpdateLibraryResultSuccess):
3179
+ logger.error("Failed to update library '%s': %s", library_name, update_result.result_details)
3180
+ update_summary[library_name] = {
3181
+ "old_version": old_version,
3182
+ "new_version": old_version,
3183
+ "status": "failed",
3184
+ "error": update_result.result_details,
3185
+ }
3186
+ continue
3187
+
3188
+ libraries_updated += 1
3189
+ update_summary[library_name] = {
3190
+ "old_version": update_result.old_version,
3191
+ "new_version": update_result.new_version,
3192
+ "status": "updated",
3193
+ }
3194
+ logger.info(
3195
+ "Successfully updated library '%s' from %s to %s",
3196
+ library_name,
3197
+ update_result.old_version,
3198
+ update_result.new_version,
3199
+ )
3200
+
3201
+ # Build result details
3202
+ details = f"Downloaded {libraries_downloaded} libraries. Checked {libraries_checked} libraries. {libraries_updated} updated."
3203
+ logger.info(details)
3204
+ return SyncLibrariesResultSuccess(
3205
+ libraries_downloaded=libraries_downloaded,
3206
+ libraries_checked=libraries_checked,
3207
+ libraries_updated=libraries_updated,
3208
+ update_summary=update_summary,
3209
+ result_details=details,
3210
+ )
3211
+
3212
+ async def inspect_library_repo_request(self, request: InspectLibraryRepoRequest) -> ResultPayload:
3213
+ """Inspect a library's metadata from a git repository without downloading the full repository."""
3214
+ git_url = request.git_url
3215
+ ref = request.ref
3216
+
3217
+ # Normalize GitHub shorthand to full URL
3218
+ from griptape_nodes.utils.git_utils import normalize_github_url, sparse_checkout_library_json
3219
+
3220
+ normalized_url = normalize_github_url(git_url)
3221
+ logger.info("Inspecting library metadata from '%s' (ref: %s)", normalized_url, ref)
3222
+
3223
+ # Perform sparse checkout to get library JSON
3224
+ try:
3225
+ library_version, commit_sha, library_data_raw = sparse_checkout_library_json(normalized_url, ref)
3226
+ except GitCloneError as e:
3227
+ details = f"Failed to inspect library from {normalized_url}: {e}"
3228
+ logger.error(details)
3229
+ return InspectLibraryRepoResultFailure(result_details=details)
3230
+
3231
+ # Validate and create LibrarySchema
3232
+ try:
3233
+ library_schema = LibrarySchema(**library_data_raw)
3234
+ except Exception as e:
3235
+ details = f"Invalid library schema from {normalized_url}: {e}"
3236
+ logger.error(details)
3237
+ return InspectLibraryRepoResultFailure(result_details=details)
3238
+
3239
+ # Return success with full library metadata
3240
+ details = f"Successfully inspected library '{library_schema.name}' (version {library_version}) from {normalized_url} at commit {commit_sha[:7]}"
3241
+ logger.info(details)
3242
+ return InspectLibraryRepoResultSuccess(
3243
+ library_schema=library_schema,
3244
+ commit_sha=commit_sha,
3245
+ git_url=normalized_url,
3246
+ ref=ref,
3247
+ result_details=details,
3248
+ )