griptape-nodes 0.60.4__py3-none-any.whl → 0.62.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 (47) hide show
  1. griptape_nodes/bootstrap/workflow_publishers/local_workflow_publisher.py +0 -1
  2. griptape_nodes/common/macro_parser/__init__.py +16 -1
  3. griptape_nodes/common/macro_parser/core.py +19 -7
  4. griptape_nodes/common/macro_parser/exceptions.py +99 -0
  5. griptape_nodes/common/macro_parser/formats.py +13 -4
  6. griptape_nodes/common/macro_parser/matching.py +5 -2
  7. griptape_nodes/common/macro_parser/parsing.py +48 -8
  8. griptape_nodes/common/macro_parser/resolution.py +23 -5
  9. griptape_nodes/common/project_templates/__init__.py +49 -0
  10. griptape_nodes/common/project_templates/default_project_template.py +87 -0
  11. griptape_nodes/common/project_templates/defaults/README.md +36 -0
  12. griptape_nodes/common/project_templates/directory.py +67 -0
  13. griptape_nodes/common/project_templates/loader.py +342 -0
  14. griptape_nodes/common/project_templates/project.py +252 -0
  15. griptape_nodes/common/project_templates/situation.py +143 -0
  16. griptape_nodes/common/project_templates/validation.py +140 -0
  17. griptape_nodes/exe_types/core_types.py +36 -3
  18. griptape_nodes/exe_types/node_types.py +4 -2
  19. griptape_nodes/exe_types/param_components/progress_bar_component.py +57 -0
  20. griptape_nodes/exe_types/param_types/parameter_audio.py +243 -0
  21. griptape_nodes/exe_types/param_types/parameter_image.py +243 -0
  22. griptape_nodes/exe_types/param_types/parameter_three_d.py +215 -0
  23. griptape_nodes/exe_types/param_types/parameter_video.py +243 -0
  24. griptape_nodes/node_library/workflow_registry.py +1 -1
  25. griptape_nodes/retained_mode/events/execution_events.py +41 -0
  26. griptape_nodes/retained_mode/events/node_events.py +90 -1
  27. griptape_nodes/retained_mode/events/os_events.py +108 -0
  28. griptape_nodes/retained_mode/events/parameter_events.py +1 -1
  29. griptape_nodes/retained_mode/events/project_events.py +528 -0
  30. griptape_nodes/retained_mode/events/workflow_events.py +19 -1
  31. griptape_nodes/retained_mode/griptape_nodes.py +9 -1
  32. griptape_nodes/retained_mode/managers/agent_manager.py +18 -24
  33. griptape_nodes/retained_mode/managers/event_manager.py +6 -9
  34. griptape_nodes/retained_mode/managers/flow_manager.py +63 -0
  35. griptape_nodes/retained_mode/managers/library_manager.py +55 -42
  36. griptape_nodes/retained_mode/managers/mcp_manager.py +14 -6
  37. griptape_nodes/retained_mode/managers/node_manager.py +232 -0
  38. griptape_nodes/retained_mode/managers/os_manager.py +399 -6
  39. griptape_nodes/retained_mode/managers/project_manager.py +1067 -0
  40. griptape_nodes/retained_mode/managers/settings.py +6 -0
  41. griptape_nodes/retained_mode/managers/sync_manager.py +4 -1
  42. griptape_nodes/retained_mode/managers/workflow_manager.py +8 -79
  43. griptape_nodes/traits/button.py +19 -0
  44. {griptape_nodes-0.60.4.dist-info → griptape_nodes-0.62.0.dist-info}/METADATA +5 -3
  45. {griptape_nodes-0.60.4.dist-info → griptape_nodes-0.62.0.dist-info}/RECORD +47 -32
  46. {griptape_nodes-0.60.4.dist-info → griptape_nodes-0.62.0.dist-info}/WHEEL +1 -1
  47. {griptape_nodes-0.60.4.dist-info → griptape_nodes-0.62.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,1067 @@
1
+ """ProjectManager - Manages project templates and file save situations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, NamedTuple
9
+
10
+ from griptape_nodes.common.macro_parser import (
11
+ MacroMatchFailure,
12
+ MacroMatchFailureReason,
13
+ MacroResolutionError,
14
+ MacroResolutionFailureReason,
15
+ ParsedMacro,
16
+ )
17
+ from griptape_nodes.common.project_templates import (
18
+ DEFAULT_PROJECT_TEMPLATE,
19
+ DirectoryDefinition,
20
+ ProjectTemplate,
21
+ ProjectValidationInfo,
22
+ ProjectValidationStatus,
23
+ SituationTemplate,
24
+ load_project_template_from_yaml,
25
+ )
26
+ from griptape_nodes.retained_mode.events.app_events import AppInitializationComplete
27
+ from griptape_nodes.retained_mode.events.os_events import ReadFileRequest, ReadFileResultSuccess
28
+ from griptape_nodes.retained_mode.events.project_events import (
29
+ AttemptMapAbsolutePathToProjectRequest,
30
+ AttemptMapAbsolutePathToProjectResultFailure,
31
+ AttemptMapAbsolutePathToProjectResultSuccess,
32
+ AttemptMatchPathAgainstMacroRequest,
33
+ AttemptMatchPathAgainstMacroResultFailure,
34
+ AttemptMatchPathAgainstMacroResultSuccess,
35
+ GetAllSituationsForProjectRequest,
36
+ GetAllSituationsForProjectResultFailure,
37
+ GetAllSituationsForProjectResultSuccess,
38
+ GetCurrentProjectRequest,
39
+ GetCurrentProjectResultFailure,
40
+ GetCurrentProjectResultSuccess,
41
+ GetPathForMacroRequest,
42
+ GetPathForMacroResultFailure,
43
+ GetPathForMacroResultSuccess,
44
+ GetProjectTemplateRequest,
45
+ GetProjectTemplateResultFailure,
46
+ GetProjectTemplateResultSuccess,
47
+ GetSituationRequest,
48
+ GetSituationResultFailure,
49
+ GetSituationResultSuccess,
50
+ GetStateForMacroRequest,
51
+ GetStateForMacroResultFailure,
52
+ GetStateForMacroResultSuccess,
53
+ ListProjectTemplatesRequest,
54
+ ListProjectTemplatesResultSuccess,
55
+ LoadProjectTemplateRequest,
56
+ LoadProjectTemplateResultFailure,
57
+ LoadProjectTemplateResultSuccess,
58
+ PathResolutionFailureReason,
59
+ ProjectTemplateInfo,
60
+ SaveProjectTemplateRequest,
61
+ SaveProjectTemplateResultFailure,
62
+ SetCurrentProjectRequest,
63
+ SetCurrentProjectResultSuccess,
64
+ )
65
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
66
+
67
+ if TYPE_CHECKING:
68
+ from griptape_nodes.retained_mode.managers.config_manager import ConfigManager
69
+ from griptape_nodes.retained_mode.managers.event_manager import EventManager
70
+ from griptape_nodes.retained_mode.managers.secrets_manager import SecretsManager
71
+
72
+ logger = logging.getLogger("griptape_nodes")
73
+
74
+ # Type alias for project identifiers
75
+ # Usually constructed from file path, but kept opaque to prevent abuse
76
+ ProjectID = str
77
+
78
+ # Synthetic identifier for the system default project template
79
+ SYSTEM_DEFAULTS_KEY: ProjectID = "<system-defaults>"
80
+
81
+ # Builtin variable name constants
82
+ BUILTIN_PROJECT_DIR = "project_dir"
83
+ BUILTIN_PROJECT_NAME = "project_name"
84
+ BUILTIN_WORKSPACE_DIR = "workspace_dir"
85
+ BUILTIN_WORKFLOW_NAME = "workflow_name"
86
+ BUILTIN_WORKFLOW_DIR = "workflow_dir"
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class BuiltinVariableInfo:
91
+ """Metadata about a builtin variable.
92
+
93
+ Attributes:
94
+ name: The variable name (e.g., "project_dir")
95
+ is_directory: Whether this variable represents a directory path
96
+ """
97
+
98
+ name: str
99
+ is_directory: bool
100
+
101
+
102
+ # Builtin variable definitions with metadata
103
+ _BUILTIN_VARIABLE_DEFINITIONS = [
104
+ BuiltinVariableInfo(name=BUILTIN_PROJECT_DIR, is_directory=True),
105
+ BuiltinVariableInfo(name=BUILTIN_PROJECT_NAME, is_directory=False),
106
+ BuiltinVariableInfo(name=BUILTIN_WORKSPACE_DIR, is_directory=True),
107
+ BuiltinVariableInfo(name=BUILTIN_WORKFLOW_NAME, is_directory=False),
108
+ BuiltinVariableInfo(name=BUILTIN_WORKFLOW_DIR, is_directory=True),
109
+ ]
110
+
111
+ # Map of variable name to metadata
112
+ _BUILTIN_VARIABLE_INFO: dict[str, BuiltinVariableInfo] = {var.name: var for var in _BUILTIN_VARIABLE_DEFINITIONS}
113
+
114
+ # Builtin variables available in all macros (read-only)
115
+ BUILTIN_VARIABLES = frozenset(var.name for var in _BUILTIN_VARIABLE_DEFINITIONS)
116
+
117
+
118
+ @dataclass
119
+ class ProjectInfo:
120
+ """Consolidated information about a loaded project.
121
+
122
+ Stores all project-related data including template, validation,
123
+ file paths, and cached parsed macros.
124
+ """
125
+
126
+ project_id: ProjectID
127
+ project_file_path: Path | None # None for system defaults or non-file sources
128
+ project_base_dir: Path # Directory for resolving relative paths ({project_dir})
129
+ template: ProjectTemplate
130
+ validation: ProjectValidationInfo
131
+
132
+ # Cached parsed macros (populated during load for performance)
133
+ parsed_situation_schemas: dict[str, ParsedMacro] # situation_name -> ParsedMacro
134
+ parsed_directory_schemas: dict[str, ParsedMacro] # directory_name -> ParsedMacro
135
+
136
+
137
+ class ProjectManager:
138
+ """Manages project templates, validation, and file path resolution.
139
+
140
+ Responsibilities:
141
+ - Load and cache project templates (system defaults + user customizations)
142
+ - Track validation status for all load attempts (including MISSING files)
143
+ - Parse and cache macro schemas for performance
144
+ - Resolve file paths using situation templates and variable substitution
145
+ - Manage current project selection
146
+ - Handle project.yml file I/O via OSManager events
147
+
148
+ State tracking uses two dicts:
149
+ - registered_template_status: ALL load attempts (Path -> ProjectValidationInfo)
150
+ - successful_templates: Only usable templates (Path -> ProjectTemplate)
151
+
152
+ This allows UI to query validation status even when template failed to load.
153
+ """
154
+
155
+ def __init__(
156
+ self,
157
+ event_manager: EventManager | None = None,
158
+ config_manager: ConfigManager | None = None,
159
+ secrets_manager: SecretsManager | None = None,
160
+ ) -> None:
161
+ """Initialize the ProjectManager.
162
+
163
+ Args:
164
+ event_manager: The EventManager instance to use for event handling
165
+ config_manager: ConfigManager instance for accessing configuration
166
+ secrets_manager: SecretsManager instance for macro resolution
167
+ """
168
+ self._config_manager = config_manager
169
+ self._secrets_manager = secrets_manager
170
+
171
+ # Consolidated project information storage
172
+ self._successfully_loaded_project_templates: dict[ProjectID, ProjectInfo] = {}
173
+ self._current_project_id: ProjectID | None = None
174
+
175
+ # Track validation status for ALL load attempts (including MISSING/UNUSABLE)
176
+ # This allows UI to query why a project failed to load
177
+ self._registered_template_status: dict[Path, ProjectValidationInfo] = {}
178
+
179
+ # Register event handlers
180
+ if event_manager is not None:
181
+ event_manager.assign_manager_to_request_type(
182
+ LoadProjectTemplateRequest, self.on_load_project_template_request
183
+ )
184
+ event_manager.assign_manager_to_request_type(
185
+ GetProjectTemplateRequest, self.on_get_project_template_request
186
+ )
187
+ event_manager.assign_manager_to_request_type(
188
+ ListProjectTemplatesRequest, self.on_list_project_templates_request
189
+ )
190
+ event_manager.assign_manager_to_request_type(GetSituationRequest, self.on_get_situation_request)
191
+ event_manager.assign_manager_to_request_type(GetPathForMacroRequest, self.on_get_path_for_macro_request)
192
+ event_manager.assign_manager_to_request_type(SetCurrentProjectRequest, self.on_set_current_project_request)
193
+ event_manager.assign_manager_to_request_type(GetCurrentProjectRequest, self.on_get_current_project_request)
194
+ event_manager.assign_manager_to_request_type(
195
+ SaveProjectTemplateRequest, self.on_save_project_template_request
196
+ )
197
+ event_manager.assign_manager_to_request_type(
198
+ AttemptMatchPathAgainstMacroRequest, self.on_match_path_against_macro_request
199
+ )
200
+ event_manager.assign_manager_to_request_type(GetStateForMacroRequest, self.on_get_state_for_macro_request)
201
+ event_manager.assign_manager_to_request_type(
202
+ GetAllSituationsForProjectRequest, self.on_get_all_situations_for_project_request
203
+ )
204
+ event_manager.assign_manager_to_request_type(
205
+ AttemptMapAbsolutePathToProjectRequest, self.on_attempt_map_absolute_path_to_project_request
206
+ )
207
+
208
+ # Register app initialization listener
209
+ event_manager.add_listener_to_app_event(
210
+ AppInitializationComplete,
211
+ self.on_app_initialization_complete,
212
+ )
213
+
214
+ def on_load_project_template_request(
215
+ self, request: LoadProjectTemplateRequest
216
+ ) -> LoadProjectTemplateResultSuccess | LoadProjectTemplateResultFailure:
217
+ """Load user's project.yml and merge with system defaults.
218
+
219
+ Flow:
220
+ 1. Issue ReadFileRequest to OSManager (for proper Windows long path handling)
221
+ 2. Parse YAML and load partial template (overlay) using load_partial_project_template()
222
+ 3. Merge with system defaults using ProjectTemplate.merge()
223
+ 4. Cache validation in registered_template_status
224
+ 5. If usable, cache template in successful_templates
225
+ 6. Return LoadProjectTemplateResultSuccess or LoadProjectTemplateResultFailure
226
+ """
227
+ read_request = ReadFileRequest(
228
+ file_path=str(request.project_path),
229
+ encoding="utf-8",
230
+ workspace_only=False,
231
+ )
232
+ read_result = GriptapeNodes.handle_request(read_request)
233
+
234
+ if read_result.failed():
235
+ validation = ProjectValidationInfo(status=ProjectValidationStatus.MISSING)
236
+ self._registered_template_status[request.project_path] = validation
237
+
238
+ return LoadProjectTemplateResultFailure(
239
+ validation=validation,
240
+ result_details=f"Attempted to load project template from '{request.project_path}'. Failed because file not found",
241
+ )
242
+
243
+ if not isinstance(read_result, ReadFileResultSuccess):
244
+ validation = ProjectValidationInfo(status=ProjectValidationStatus.UNUSABLE)
245
+ self._registered_template_status[request.project_path] = validation
246
+
247
+ return LoadProjectTemplateResultFailure(
248
+ validation=validation,
249
+ result_details=f"Attempted to load project template from '{request.project_path}'. Failed because file read returned unexpected result type",
250
+ )
251
+
252
+ yaml_text = read_result.content
253
+ if not isinstance(yaml_text, str):
254
+ validation = ProjectValidationInfo(status=ProjectValidationStatus.UNUSABLE)
255
+ self._registered_template_status[request.project_path] = validation
256
+
257
+ return LoadProjectTemplateResultFailure(
258
+ validation=validation,
259
+ result_details=f"Attempted to load project template from '{request.project_path}'. Failed because template must be text, got binary content",
260
+ )
261
+
262
+ validation = ProjectValidationInfo(status=ProjectValidationStatus.GOOD)
263
+ template = load_project_template_from_yaml(yaml_text, validation)
264
+
265
+ if template is None:
266
+ self._registered_template_status[request.project_path] = validation
267
+ return LoadProjectTemplateResultFailure(
268
+ validation=validation,
269
+ result_details=f"Attempted to load project template from '{request.project_path}'. Failed because YAML could not be parsed",
270
+ )
271
+
272
+ # Generate project_id from file path
273
+ project_file_path = Path(request.project_path)
274
+ project_id = str(project_file_path)
275
+ project_base_dir = project_file_path.parent
276
+
277
+ # Parse all macros BEFORE creating ProjectInfo - collect ALL errors
278
+ situation_schemas = self._parse_situation_macros(template.situations, validation)
279
+ directory_schemas = self._parse_directory_macros(template.directories, validation)
280
+
281
+ # Now check if validation is usable after collecting all errors
282
+ if not validation.is_usable():
283
+ self._registered_template_status[request.project_path] = validation
284
+ return LoadProjectTemplateResultFailure(
285
+ validation=validation,
286
+ result_details=f"Attempted to load project template from '{request.project_path}'. Failed because template is not usable (status: {validation.status})",
287
+ )
288
+
289
+ # Create consolidated ProjectInfo with fully populated macro caches
290
+ project_info = ProjectInfo(
291
+ project_id=project_id,
292
+ project_file_path=project_file_path,
293
+ project_base_dir=project_base_dir,
294
+ template=template,
295
+ validation=validation,
296
+ parsed_situation_schemas=situation_schemas,
297
+ parsed_directory_schemas=directory_schemas,
298
+ )
299
+
300
+ # Store in new consolidated dict
301
+ self._successfully_loaded_project_templates[project_id] = project_info
302
+
303
+ # Track validation status for all load attempts (for UI display)
304
+ self._registered_template_status[request.project_path] = validation
305
+
306
+ return LoadProjectTemplateResultSuccess(
307
+ project_id=project_id,
308
+ template=template,
309
+ validation=validation,
310
+ result_details=f"Template loaded successfully with status: {validation.status}",
311
+ )
312
+
313
+ def on_get_project_template_request(
314
+ self, request: GetProjectTemplateRequest
315
+ ) -> GetProjectTemplateResultSuccess | GetProjectTemplateResultFailure:
316
+ """Get cached template for a project ID."""
317
+ project_info = self._successfully_loaded_project_templates.get(request.project_id)
318
+
319
+ if project_info is None:
320
+ return GetProjectTemplateResultFailure(
321
+ result_details=f"Attempted to get project template for '{request.project_id}'. Failed because template not loaded yet",
322
+ )
323
+
324
+ return GetProjectTemplateResultSuccess(
325
+ template=project_info.template,
326
+ validation=project_info.validation,
327
+ result_details=f"Successfully retrieved project template for '{request.project_id}'. Status: {project_info.validation.status}",
328
+ )
329
+
330
+ def on_list_project_templates_request(
331
+ self, request: ListProjectTemplatesRequest
332
+ ) -> ListProjectTemplatesResultSuccess:
333
+ """List all project templates that have been loaded or attempted to load.
334
+
335
+ Returns separate lists for successfully loaded and failed templates.
336
+ """
337
+ successfully_loaded: list[ProjectTemplateInfo] = []
338
+ failed_to_load: list[ProjectTemplateInfo] = []
339
+
340
+ # Gather successfully loaded templates from _successfully_loaded_project_templates
341
+ for project_id, project_info in self._successfully_loaded_project_templates.items():
342
+ # Skip system builtins unless requested
343
+ if not request.include_system_builtins and project_id == SYSTEM_DEFAULTS_KEY:
344
+ continue
345
+
346
+ successfully_loaded.append(ProjectTemplateInfo(project_id=project_id, validation=project_info.validation))
347
+
348
+ # Gather failed templates from _registered_template_status
349
+ # These are tracked by Path, not ProjectID
350
+ for template_path, validation in self._registered_template_status.items():
351
+ project_id = str(template_path)
352
+
353
+ # Skip if already in successfully loaded (validation status might be FLAWED but still loaded)
354
+ if project_id in self._successfully_loaded_project_templates:
355
+ continue
356
+
357
+ # Skip system builtins unless requested
358
+ if not request.include_system_builtins and project_id == SYSTEM_DEFAULTS_KEY:
359
+ continue
360
+
361
+ # Only include if status indicates failure (UNUSABLE or MISSING)
362
+ if not validation.is_usable():
363
+ failed_to_load.append(ProjectTemplateInfo(project_id=project_id, validation=validation))
364
+
365
+ return ListProjectTemplatesResultSuccess(
366
+ successfully_loaded=successfully_loaded,
367
+ failed_to_load=failed_to_load,
368
+ result_details=f"Successfully listed project templates. Loaded: {len(successfully_loaded)}, Failed: {len(failed_to_load)}",
369
+ )
370
+
371
+ def on_get_situation_request(
372
+ self, request: GetSituationRequest
373
+ ) -> GetSituationResultSuccess | GetSituationResultFailure:
374
+ """Get the complete situation template for a specific situation.
375
+
376
+ Returns the full SituationTemplate including macro and policy.
377
+
378
+ Flow:
379
+ 1. Get current project
380
+ 2. Get template from successful_templates
381
+ 3. Get situation from template
382
+ 4. Return complete SituationTemplate
383
+ """
384
+ current_project_request = GetCurrentProjectRequest()
385
+ current_project_result = self.on_get_current_project_request(current_project_request)
386
+
387
+ if not isinstance(current_project_result, GetCurrentProjectResultSuccess):
388
+ return GetSituationResultFailure(
389
+ result_details=f"Attempted to get situation '{request.situation_name}'. Failed because no current project is set or template not loaded",
390
+ )
391
+
392
+ template = current_project_result.project_info.template
393
+
394
+ situation = template.situations.get(request.situation_name)
395
+ if situation is None:
396
+ return GetSituationResultFailure(
397
+ result_details=f"Attempted to get situation '{request.situation_name}'. Failed because situation not found",
398
+ )
399
+
400
+ return GetSituationResultSuccess(
401
+ situation=situation,
402
+ result_details=f"Successfully retrieved situation '{request.situation_name}'. Macro: {situation.macro}, Policy: create_dirs={situation.policy.create_dirs}, on_collision={situation.policy.on_collision}",
403
+ )
404
+
405
+ def on_get_path_for_macro_request( # noqa: C901, PLR0911, PLR0912, PLR0915
406
+ self, request: GetPathForMacroRequest
407
+ ) -> GetPathForMacroResultSuccess | GetPathForMacroResultFailure:
408
+ """Resolve ANY macro schema with variables to final Path.
409
+
410
+ Flow:
411
+ 1. Get current project
412
+ 2. Get variables from ParsedMacro.get_variables()
413
+ 3. For each variable:
414
+ - If in directories dict → resolve directory, add to resolution bag
415
+ - Else if in user_supplied_vars → use user value
416
+ - If in BOTH → ERROR: DIRECTORY_OVERRIDE_ATTEMPTED
417
+ - Else → collect as missing
418
+ 4. If any missing → ERROR: MISSING_REQUIRED_VARIABLES
419
+ 5. Resolve macro with complete variable bag
420
+ 6. Return resolved Path
421
+ """
422
+ current_project_request = GetCurrentProjectRequest()
423
+ current_project_result = self.on_get_current_project_request(current_project_request)
424
+
425
+ if not isinstance(current_project_result, GetCurrentProjectResultSuccess):
426
+ return GetPathForMacroResultFailure(
427
+ failure_reason=PathResolutionFailureReason.MACRO_RESOLUTION_ERROR,
428
+ result_details="Attempted to resolve macro path. Failed because no current project is set or template not loaded",
429
+ )
430
+
431
+ project_info = current_project_result.project_info
432
+ template = project_info.template
433
+
434
+ variable_infos = request.parsed_macro.get_variables()
435
+ directory_names = set(template.directories.keys())
436
+ user_provided_names = set(request.variables.keys())
437
+
438
+ # Check for directory/user variable name conflicts
439
+ conflicting = directory_names & user_provided_names
440
+ if conflicting:
441
+ return GetPathForMacroResultFailure(
442
+ failure_reason=PathResolutionFailureReason.DIRECTORY_OVERRIDE_ATTEMPTED,
443
+ conflicting_variables=conflicting,
444
+ result_details=f"Attempted to resolve macro path. Failed because variables conflict with directory names: {', '.join(sorted(conflicting))}",
445
+ )
446
+
447
+ resolution_bag: dict[str, str | int] = {}
448
+ disallowed_overrides: set[str] = set()
449
+
450
+ for var_info in variable_infos:
451
+ var_name = var_info.name
452
+
453
+ if var_name in directory_names:
454
+ directory_def = template.directories[var_name]
455
+ resolution_bag[var_name] = directory_def.path_macro
456
+ elif var_name in user_provided_names:
457
+ resolution_bag[var_name] = request.variables[var_name]
458
+
459
+ if var_name in BUILTIN_VARIABLES:
460
+ try:
461
+ builtin_value = self._get_builtin_variable_value(var_name, project_info)
462
+ except (RuntimeError, NotImplementedError) as e:
463
+ return GetPathForMacroResultFailure(
464
+ failure_reason=PathResolutionFailureReason.MACRO_RESOLUTION_ERROR,
465
+ result_details=f"Attempted to resolve macro path. Failed because builtin variable '{var_name}' cannot be resolved: {e}",
466
+ )
467
+ # Confirm no monkey business with trying to override builtin values
468
+ existing = resolution_bag.get(var_name)
469
+ if existing is not None:
470
+ # For directory builtin variables, compare as resolved paths
471
+ builtin_info = _BUILTIN_VARIABLE_INFO.get(var_name)
472
+ if builtin_info and builtin_info.is_directory:
473
+ os_manager = GriptapeNodes.OSManager()
474
+ resolved_existing = os_manager.resolve_path_safely(Path(str(existing)))
475
+ resolved_builtin = os_manager.resolve_path_safely(Path(builtin_value))
476
+ if resolved_existing != resolved_builtin:
477
+ disallowed_overrides.add(var_name)
478
+ elif str(existing) != builtin_value:
479
+ disallowed_overrides.add(var_name)
480
+ else:
481
+ resolution_bag[var_name] = builtin_value
482
+
483
+ # Check if user tried to override builtins with different values
484
+ if disallowed_overrides:
485
+ return GetPathForMacroResultFailure(
486
+ failure_reason=PathResolutionFailureReason.DIRECTORY_OVERRIDE_ATTEMPTED,
487
+ conflicting_variables=disallowed_overrides,
488
+ result_details=f"Attempted to resolve macro path. Failed because cannot override builtin variables: {', '.join(sorted(disallowed_overrides))}",
489
+ )
490
+
491
+ required_vars = {v.name for v in variable_infos if v.is_required}
492
+ provided_vars = set(resolution_bag.keys())
493
+ missing = required_vars - provided_vars
494
+
495
+ if missing:
496
+ return GetPathForMacroResultFailure(
497
+ failure_reason=PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES,
498
+ missing_variables=missing,
499
+ result_details=f"Attempted to resolve macro path. Failed because missing required variables: {', '.join(sorted(missing))}",
500
+ )
501
+
502
+ if self._secrets_manager is None:
503
+ return GetPathForMacroResultFailure(
504
+ failure_reason=PathResolutionFailureReason.MACRO_RESOLUTION_ERROR,
505
+ result_details="Attempted to resolve macro path. Failed because SecretsManager is not available",
506
+ )
507
+
508
+ try:
509
+ resolved_string = request.parsed_macro.resolve(resolution_bag, self._secrets_manager)
510
+ except MacroResolutionError as e:
511
+ if e.failure_reason == MacroResolutionFailureReason.MISSING_REQUIRED_VARIABLES:
512
+ path_failure_reason = PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES
513
+ else:
514
+ path_failure_reason = PathResolutionFailureReason.MACRO_RESOLUTION_ERROR
515
+
516
+ return GetPathForMacroResultFailure(
517
+ failure_reason=path_failure_reason,
518
+ missing_variables=e.missing_variables,
519
+ result_details=f"Attempted to resolve macro path. Failed because macro resolution error: {e}",
520
+ )
521
+
522
+ resolved_path = Path(resolved_string)
523
+
524
+ # Make absolute path by resolving against project base directory
525
+ if resolved_path.is_absolute():
526
+ absolute_path = resolved_path
527
+ else:
528
+ absolute_path = project_info.project_base_dir / resolved_path
529
+
530
+ return GetPathForMacroResultSuccess(
531
+ resolved_path=resolved_path,
532
+ absolute_path=absolute_path,
533
+ result_details=f"Successfully resolved macro path. Result: {resolved_path}",
534
+ )
535
+
536
+ def on_set_current_project_request(self, request: SetCurrentProjectRequest) -> SetCurrentProjectResultSuccess:
537
+ """Set which project user has selected."""
538
+ self._current_project_id = request.project_id
539
+
540
+ if request.project_id is None:
541
+ return SetCurrentProjectResultSuccess(
542
+ result_details="Successfully set current project. No project selected",
543
+ )
544
+
545
+ return SetCurrentProjectResultSuccess(
546
+ result_details=f"Successfully set current project. ID: {request.project_id}",
547
+ )
548
+
549
+ def on_get_current_project_request(
550
+ self, _request: GetCurrentProjectRequest
551
+ ) -> GetCurrentProjectResultSuccess | GetCurrentProjectResultFailure:
552
+ """Get currently selected project with template info."""
553
+ if self._current_project_id is None:
554
+ return GetCurrentProjectResultFailure(
555
+ result_details="Attempted to get current project. Failed because no project is currently set"
556
+ )
557
+
558
+ project_info = self._successfully_loaded_project_templates.get(self._current_project_id)
559
+ if project_info is None:
560
+ return GetCurrentProjectResultFailure(
561
+ result_details=f"Attempted to get current project. Failed because project not found for ID: '{self._current_project_id}'"
562
+ )
563
+
564
+ return GetCurrentProjectResultSuccess(
565
+ project_info=project_info,
566
+ result_details=f"Successfully retrieved current project. ID: {self._current_project_id}",
567
+ )
568
+
569
+ def on_save_project_template_request(self, request: SaveProjectTemplateRequest) -> SaveProjectTemplateResultFailure:
570
+ """Save user customizations to project.yml.
571
+
572
+ Flow:
573
+ 1. Convert template_data to YAML format
574
+ 2. Issue WriteFileRequest to OSManager
575
+ 3. Handle write result
576
+ 4. Invalidate cache (force reload on next access)
577
+
578
+ TODO: Implement saving logic when template system merges
579
+ """
580
+ return SaveProjectTemplateResultFailure(
581
+ result_details=f"Attempted to save project template to '{request.project_path}'. Failed because template saving not yet implemented",
582
+ )
583
+
584
+ def on_match_path_against_macro_request(
585
+ self, request: AttemptMatchPathAgainstMacroRequest
586
+ ) -> AttemptMatchPathAgainstMacroResultSuccess | AttemptMatchPathAgainstMacroResultFailure:
587
+ """Attempt to match a path against a macro schema and extract variables.
588
+
589
+ Flow:
590
+ 1. Check secrets manager is available (failure = true error)
591
+ 2. Call ParsedMacro.extract_variables() with path and known variables
592
+ 3. If match succeeds, return success with extracted_variables
593
+ 4. If match fails, return success with match_failure (not an error)
594
+ """
595
+ if self._secrets_manager is None:
596
+ return AttemptMatchPathAgainstMacroResultFailure(
597
+ result_details=f"Attempted to match path '{request.file_path}' against macro '{request.parsed_macro.template}'. Failed because SecretsManager not available",
598
+ )
599
+
600
+ extracted = request.parsed_macro.extract_variables(
601
+ request.file_path,
602
+ request.known_variables,
603
+ self._secrets_manager,
604
+ )
605
+
606
+ if extracted is None:
607
+ # Pattern didn't match - this is a normal outcome, not an error
608
+ return AttemptMatchPathAgainstMacroResultSuccess(
609
+ extracted_variables=None,
610
+ match_failure=MacroMatchFailure(
611
+ failure_reason=MacroMatchFailureReason.STATIC_TEXT_MISMATCH,
612
+ expected_pattern=request.parsed_macro.template,
613
+ known_variables_used=request.known_variables,
614
+ error_details=f"Path '{request.file_path}' does not match macro pattern",
615
+ ),
616
+ result_details=f"Attempted to match path '{request.file_path}' against macro '{request.parsed_macro.template}'. Pattern did not match",
617
+ )
618
+
619
+ # Pattern matched successfully
620
+ return AttemptMatchPathAgainstMacroResultSuccess(
621
+ extracted_variables=extracted,
622
+ match_failure=None,
623
+ result_details=f"Successfully matched path '{request.file_path}' against macro '{request.parsed_macro.template}'. Extracted {len(extracted)} variables",
624
+ )
625
+
626
+ def on_get_state_for_macro_request( # noqa: C901
627
+ self, request: GetStateForMacroRequest
628
+ ) -> GetStateForMacroResultSuccess | GetStateForMacroResultFailure:
629
+ """Analyze a macro and return comprehensive state information.
630
+
631
+ Flow:
632
+ 1. Get current project via GetCurrentProjectRequest
633
+ 2. Get template from current project
634
+ 3. For each variable, determine if it's:
635
+ - A directory (from template)
636
+ - User-provided (from request)
637
+ - A builtin
638
+ 4. Check for conflicts:
639
+ - User providing directory name
640
+ - User overriding builtin with different value
641
+ 5. Calculate what's satisfied vs missing
642
+ 6. Determine if resolution would succeed
643
+ """
644
+ current_project_request = GetCurrentProjectRequest()
645
+ current_project_result = self.on_get_current_project_request(current_project_request)
646
+
647
+ if not isinstance(current_project_result, GetCurrentProjectResultSuccess):
648
+ return GetStateForMacroResultFailure(
649
+ result_details="Attempted to analyze macro state. Failed because no current project is set or template not loaded",
650
+ )
651
+
652
+ project_info = current_project_result.project_info
653
+ template = project_info.template
654
+
655
+ all_variables = request.parsed_macro.get_variables()
656
+ directory_names = set(template.directories.keys())
657
+ user_provided_names = set(request.variables.keys())
658
+
659
+ satisfied_variables: set[str] = set()
660
+ missing_required_variables: set[str] = set()
661
+ conflicting_variables: set[str] = set()
662
+
663
+ for var_info in all_variables:
664
+ var_name = var_info.name
665
+
666
+ if var_name in directory_names:
667
+ satisfied_variables.add(var_name)
668
+ if var_name in user_provided_names:
669
+ conflicting_variables.add(var_name)
670
+
671
+ if var_name in user_provided_names:
672
+ satisfied_variables.add(var_name)
673
+
674
+ if var_name in BUILTIN_VARIABLES:
675
+ try:
676
+ builtin_value = self._get_builtin_variable_value(var_name, project_info)
677
+ except (RuntimeError, NotImplementedError) as e:
678
+ return GetStateForMacroResultFailure(
679
+ result_details=f"Attempted to analyze macro state. Failed because builtin variable '{var_name}' cannot be resolved: {e}",
680
+ )
681
+
682
+ satisfied_variables.add(var_name)
683
+ if var_name in user_provided_names:
684
+ user_value = str(request.variables[var_name])
685
+ if user_value != builtin_value:
686
+ conflicting_variables.add(var_name)
687
+
688
+ if var_info.is_required and var_name not in satisfied_variables:
689
+ missing_required_variables.add(var_name)
690
+
691
+ can_resolve = len(missing_required_variables) == 0 and len(conflicting_variables) == 0
692
+
693
+ return GetStateForMacroResultSuccess(
694
+ all_variables=all_variables,
695
+ satisfied_variables=satisfied_variables,
696
+ missing_required_variables=missing_required_variables,
697
+ conflicting_variables=conflicting_variables,
698
+ can_resolve=can_resolve,
699
+ result_details=f"Analyzed macro with {len(all_variables)} variables: {len(satisfied_variables)} satisfied, {len(missing_required_variables)} missing, {len(conflicting_variables)} conflicting",
700
+ )
701
+
702
+ async def on_app_initialization_complete(self, _payload: AppInitializationComplete) -> None:
703
+ """Load system default project template when app initializes.
704
+
705
+ Called by EventManager after all libraries are loaded.
706
+ """
707
+ self._load_system_defaults()
708
+
709
+ # Set as current project (using synthetic key for system defaults)
710
+ set_request = SetCurrentProjectRequest(project_id=SYSTEM_DEFAULTS_KEY)
711
+ result = self.on_set_current_project_request(set_request)
712
+
713
+ if result.failed():
714
+ logger.error("Failed to set default project as current: %s", result.result_details)
715
+ else:
716
+ logger.debug("Successfully loaded default project template")
717
+
718
+ def on_get_all_situations_for_project_request(
719
+ self, _request: GetAllSituationsForProjectRequest
720
+ ) -> GetAllSituationsForProjectResultSuccess | GetAllSituationsForProjectResultFailure:
721
+ """Get all situation names and schemas from current project template."""
722
+ current_project_request = GetCurrentProjectRequest()
723
+ current_project_result = self.on_get_current_project_request(current_project_request)
724
+
725
+ if not isinstance(current_project_result, GetCurrentProjectResultSuccess):
726
+ return GetAllSituationsForProjectResultFailure(
727
+ result_details="Attempted to get all situations. Failed because no current project is set or template not loaded"
728
+ )
729
+
730
+ template = current_project_result.project_info.template
731
+ situations = {situation_name: situation.macro for situation_name, situation in template.situations.items()}
732
+
733
+ return GetAllSituationsForProjectResultSuccess(
734
+ situations=situations,
735
+ result_details=f"Successfully retrieved all situations. Found {len(situations)} situations",
736
+ )
737
+
738
+ def on_attempt_map_absolute_path_to_project_request(
739
+ self, request: AttemptMapAbsolutePathToProjectRequest
740
+ ) -> AttemptMapAbsolutePathToProjectResultSuccess | AttemptMapAbsolutePathToProjectResultFailure:
741
+ """Find out if an absolute path exists anywhere within a Project directory.
742
+
743
+ Returns Success with mapped_path if inside project (macro form returned).
744
+ Returns Success with None if outside project (valid answer: "not in project").
745
+ Returns Failure if operation cannot be performed (no project, no secrets manager).
746
+
747
+ Args:
748
+ request: Request containing the absolute path to check
749
+
750
+ Returns:
751
+ Success with mapped_path if path is inside project
752
+ Success with None if path is outside project
753
+ Failure if operation cannot be performed
754
+ """
755
+ # Check prerequisites - return Failure if missing
756
+ current_project_request = GetCurrentProjectRequest()
757
+ current_project_result = self.on_get_current_project_request(current_project_request)
758
+
759
+ if not isinstance(current_project_result, GetCurrentProjectResultSuccess):
760
+ return AttemptMapAbsolutePathToProjectResultFailure(
761
+ result_details="Attempted to map absolute path. Failed because no current project is set"
762
+ )
763
+
764
+ if self._secrets_manager is None:
765
+ return AttemptMapAbsolutePathToProjectResultFailure(
766
+ result_details="Attempted to map absolute path. Failed because SecretsManager not available"
767
+ )
768
+
769
+ project_info = current_project_result.project_info
770
+
771
+ # Try to map the path
772
+ try:
773
+ mapped_path = self._absolute_path_to_macro_path(request.absolute_path, project_info)
774
+ except (RuntimeError, NotImplementedError) as e:
775
+ # Variable resolution failed - this is a Failure (can't complete the operation)
776
+ return AttemptMapAbsolutePathToProjectResultFailure(
777
+ result_details=f"Attempted to map absolute path '{request.absolute_path}'. Failed because: {e}"
778
+ )
779
+
780
+ # Path successfully checked
781
+ if mapped_path is None:
782
+ # Success: we successfully determined the path is outside project
783
+ return AttemptMapAbsolutePathToProjectResultSuccess(
784
+ mapped_path=None,
785
+ result_details=f"Attempted to map absolute path '{request.absolute_path}'. Path is outside all project directories",
786
+ )
787
+
788
+ # Success: path mapped to macro form
789
+ return AttemptMapAbsolutePathToProjectResultSuccess(
790
+ mapped_path=mapped_path,
791
+ result_details=f"Successfully mapped absolute path to '{mapped_path}'",
792
+ )
793
+
794
+ # Helper methods (private)
795
+
796
+ @staticmethod
797
+ def _parse_situation_macros(
798
+ situations: dict[str, SituationTemplate], validation: ProjectValidationInfo
799
+ ) -> dict[str, ParsedMacro]:
800
+ """Parse all situation macros.
801
+
802
+ This is called BEFORE creating ProjectInfo to ensure all macros are valid.
803
+ Collects all parsing errors into the validation object instead of raising.
804
+
805
+ Args:
806
+ situations: Dictionary of situation templates to parse
807
+ validation: Validation object to collect errors
808
+
809
+ Returns:
810
+ Dictionary mapping situation_name to ParsedMacro (only for successfully parsed macros)
811
+ """
812
+ situation_schemas: dict[str, ParsedMacro] = {}
813
+
814
+ for situation_name, situation in situations.items():
815
+ try:
816
+ situation_schemas[situation_name] = ParsedMacro(situation.macro)
817
+ except Exception as e:
818
+ validation.add_error(f"situations.{situation_name}.macro", f"Failed to parse macro: {e}")
819
+
820
+ return situation_schemas
821
+
822
+ @staticmethod
823
+ def _parse_directory_macros(
824
+ directories: dict[str, DirectoryDefinition], validation: ProjectValidationInfo
825
+ ) -> dict[str, ParsedMacro]:
826
+ """Parse all directory macros.
827
+
828
+ This is called BEFORE creating ProjectInfo to ensure all macros are valid.
829
+ Collects all parsing errors into the validation object instead of raising.
830
+
831
+ Args:
832
+ directories: Dictionary of directory definitions to parse
833
+ validation: Validation object to collect errors
834
+
835
+ Returns:
836
+ Dictionary mapping directory_name to ParsedMacro (only for successfully parsed macros)
837
+ """
838
+ directory_schemas: dict[str, ParsedMacro] = {}
839
+
840
+ for directory_name, directory_def in directories.items():
841
+ try:
842
+ directory_schemas[directory_name] = ParsedMacro(directory_def.path_macro)
843
+ except Exception as e:
844
+ validation.add_error(f"directories.{directory_name}.path_macro", f"Failed to parse macro: {e}")
845
+
846
+ return directory_schemas
847
+
848
+ def _get_builtin_variable_value(self, var_name: str, project_info: ProjectInfo) -> str:
849
+ """Get the value of a single builtin variable.
850
+
851
+ Args:
852
+ var_name: Name of the builtin variable
853
+ project_info: Information about the current project
854
+
855
+ Returns:
856
+ String value of the builtin variable
857
+
858
+ Raises:
859
+ ValueError: If var_name is not a recognized builtin variable
860
+ NotImplementedError: If builtin variable is not yet implemented
861
+ """
862
+ match var_name:
863
+ case "project_dir":
864
+ return str(project_info.project_base_dir)
865
+
866
+ case "project_name":
867
+ msg = f"{BUILTIN_PROJECT_NAME} not yet implemented"
868
+ raise NotImplementedError(msg)
869
+
870
+ case "workspace_dir":
871
+ config_manager = GriptapeNodes.ConfigManager()
872
+ workspace_dir = config_manager.get_config_value("workspace_directory")
873
+ if workspace_dir is None:
874
+ msg = "Attempted to resolve builtin variable '{workspace_dir}'. Failed because 'workspace_directory' config value was None"
875
+ raise RuntimeError(msg)
876
+ return str(workspace_dir)
877
+
878
+ case "workflow_name":
879
+ context_manager = GriptapeNodes.ContextManager()
880
+ if not context_manager.has_current_workflow():
881
+ msg = "No current workflow"
882
+ raise RuntimeError(msg)
883
+ return context_manager.get_current_workflow_name()
884
+
885
+ case "workflow_dir":
886
+ msg = f"{BUILTIN_WORKFLOW_DIR} not yet implemented"
887
+ raise NotImplementedError(msg)
888
+
889
+ case _:
890
+ msg = f"Unknown builtin variable: {var_name}"
891
+ raise ValueError(msg)
892
+
893
+ def _absolute_path_to_macro_path( # noqa: C901, PLR0912
894
+ self, absolute_path: Path, project_info: ProjectInfo
895
+ ) -> str | None:
896
+ """Convert an absolute path to macro form using longest prefix matching.
897
+
898
+ Resolves all project directories at runtime (to support env vars and macros),
899
+ then checks if the absolute path is within any of them.
900
+ Uses longest prefix matching to find the best match.
901
+
902
+ Args:
903
+ absolute_path: Absolute path to convert (e.g., /Users/james/project/outputs/file.png)
904
+ project_info: Information about the current project
905
+
906
+ Returns:
907
+ Macro-ified path (e.g., {outputs}/file.png) if inside a project directory,
908
+ or None if outside all project directories
909
+
910
+ Raises:
911
+ RuntimeError: If directory resolution fails or builtin variable cannot be resolved
912
+ NotImplementedError: If a required builtin variable is not yet implemented
913
+
914
+ Examples:
915
+ /Users/james/project/outputs/renders/file.png → "{outputs}/renders/file.png"
916
+ /Users/james/project/outputs/inputs/file.png → "{outputs}/inputs/file.png"
917
+ /Users/james/Downloads/file.png → None
918
+ """
919
+ # Normalize paths for consistent cross-platform comparison
920
+ os_manager = GriptapeNodes.OSManager()
921
+ absolute_path = os_manager.resolve_path_safely(absolute_path)
922
+
923
+ template = project_info.template
924
+ project_base_dir = os_manager.resolve_path_safely(project_info.project_base_dir)
925
+
926
+ # Secrets manager must be available (checked by caller)
927
+ if self._secrets_manager is None:
928
+ msg = "SecretsManager not available"
929
+ raise RuntimeError(msg)
930
+ secrets_manager = self._secrets_manager
931
+
932
+ # Collect all variables used across ALL directory macros
933
+ variables_needed: set[str] = set()
934
+ for parsed_macro in project_info.parsed_directory_schemas.values():
935
+ variable_infos = parsed_macro.get_variables()
936
+ variables_needed.update(var_info.name for var_info in variable_infos)
937
+
938
+ # Build builtin variables dict - only resolve variables actually needed by the macros
939
+ # If a required variable fails to resolve, let the error propagate (will be caught by handler)
940
+ builtin_vars: dict[str, str | int] = {}
941
+ for var_name in variables_needed:
942
+ if var_name in BUILTIN_VARIABLES:
943
+ builtin_vars[var_name] = self._get_builtin_variable_value(var_name, project_info)
944
+
945
+ # Find all matching directories (where absolute_path is inside the directory)
946
+ class DirectoryMatch(NamedTuple):
947
+ directory_name: str
948
+ resolved_path: Path
949
+ prefix_length: int
950
+
951
+ matches: list[DirectoryMatch] = []
952
+
953
+ for directory_name in template.directories:
954
+ # Get parsed macro from project info cache
955
+ parsed_macro = project_info.parsed_directory_schemas.get(directory_name)
956
+ if parsed_macro is None:
957
+ msg = f"Directory '{directory_name}' not found in parsed schemas"
958
+ raise RuntimeError(msg)
959
+
960
+ try:
961
+ resolved_path_str = parsed_macro.resolve(builtin_vars, secrets_manager)
962
+ except MacroResolutionError as e:
963
+ msg = f"Failed to resolve directory '{directory_name}' macro: {e}"
964
+ raise RuntimeError(msg) from e
965
+
966
+ # Make absolute (resolve relative paths against project base directory)
967
+ resolved_dir_path = Path(resolved_path_str)
968
+ if not resolved_dir_path.is_absolute():
969
+ resolved_dir_path = project_base_dir / resolved_dir_path
970
+ # Normalize for consistent cross-platform comparison
971
+ resolved_dir_path = os_manager.resolve_path_safely(resolved_dir_path)
972
+
973
+ # Check if absolute_path is inside this directory
974
+ try:
975
+ # relative_to will raise ValueError if not a subpath
976
+ _ = absolute_path.relative_to(resolved_dir_path)
977
+ # Track the match with its prefix length (for longest match)
978
+ matches.append(
979
+ DirectoryMatch(
980
+ directory_name=directory_name,
981
+ resolved_path=resolved_dir_path,
982
+ prefix_length=len(resolved_dir_path.parts),
983
+ )
984
+ )
985
+ except ValueError:
986
+ # Not a subpath, skip
987
+ continue
988
+
989
+ # If no defined directories matched, try {project_dir} as fallback
990
+ if not matches:
991
+ # Check if path is inside project_base_dir
992
+ try:
993
+ relative_path = absolute_path.relative_to(project_base_dir)
994
+
995
+ # Convert to {project_dir} macro form
996
+ if str(relative_path) == ".":
997
+ return "{project_dir}"
998
+ return f"{{project_dir}}/{relative_path.as_posix()}"
999
+ except ValueError:
1000
+ # Not inside project_base_dir either
1001
+ return None
1002
+
1003
+ # Use longest prefix match (most specific directory)
1004
+ best_match = matches[0]
1005
+ for match in matches:
1006
+ if match.prefix_length > best_match.prefix_length:
1007
+ best_match = match
1008
+
1009
+ # Calculate relative path from the matched directory
1010
+ relative_path = absolute_path.relative_to(best_match.resolved_path)
1011
+
1012
+ # Convert to macro form
1013
+ if str(relative_path) == ".":
1014
+ # File is directly in the directory root
1015
+ # Example: /Users/james/project/outputs → {outputs}
1016
+ return f"{{{best_match.directory_name}}}"
1017
+
1018
+ # File is in a subdirectory
1019
+ # Example: /Users/james/project/outputs/renders/final.png → {outputs}/renders/final.png
1020
+ return f"{{{best_match.directory_name}}}/{relative_path.as_posix()}"
1021
+
1022
+ # Private helper methods
1023
+
1024
+ def _load_system_defaults(self) -> None:
1025
+ """Load bundled system default template.
1026
+
1027
+ System defaults are now defined in Python as DEFAULT_PROJECT_TEMPLATE.
1028
+ This is always valid by construction.
1029
+ """
1030
+ logger.debug("Loading system default template")
1031
+
1032
+ # Create validation info to track that defaults were loaded
1033
+ validation = ProjectValidationInfo(status=ProjectValidationStatus.GOOD)
1034
+
1035
+ # System defaults use workspace directory as the base directory
1036
+ config_manager = GriptapeNodes.ConfigManager()
1037
+ workspace_dir_value = config_manager.get_config_value("workspace_directory")
1038
+ if workspace_dir_value is None:
1039
+ msg = "Attempted to load Project Manager's default project schema. Failed because 'workspace_directory' config value was None"
1040
+ raise RuntimeError(msg)
1041
+
1042
+ try:
1043
+ workspace_dir = Path(workspace_dir_value)
1044
+ except (TypeError, ValueError) as e:
1045
+ msg = f"Attempted to load Project Manager's default project schema with workspace_directory='{workspace_dir_value}'. Failed due to {e}"
1046
+ logger.error(msg)
1047
+ raise RuntimeError(msg) from e
1048
+
1049
+ # Parse all macros BEFORE creating ProjectInfo (system defaults should always be valid)
1050
+ situation_schemas = self._parse_situation_macros(DEFAULT_PROJECT_TEMPLATE.situations, validation)
1051
+ directory_schemas = self._parse_directory_macros(DEFAULT_PROJECT_TEMPLATE.directories, validation)
1052
+
1053
+ # Create consolidated ProjectInfo with fully populated macro caches
1054
+ project_info = ProjectInfo(
1055
+ project_id=SYSTEM_DEFAULTS_KEY,
1056
+ project_file_path=None, # No actual file for system defaults
1057
+ project_base_dir=workspace_dir, # Use workspace as base
1058
+ template=DEFAULT_PROJECT_TEMPLATE,
1059
+ validation=validation,
1060
+ parsed_situation_schemas=situation_schemas,
1061
+ parsed_directory_schemas=directory_schemas,
1062
+ )
1063
+
1064
+ # Store in new consolidated dict
1065
+ self._successfully_loaded_project_templates[SYSTEM_DEFAULTS_KEY] = project_info
1066
+
1067
+ logger.debug("System defaults loaded successfully")