griptape-nodes 0.61.0__py3-none-any.whl → 0.62.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. griptape_nodes/common/macro_parser/core.py +4 -4
  2. griptape_nodes/common/macro_parser/exceptions.py +3 -3
  3. griptape_nodes/common/macro_parser/resolution.py +2 -2
  4. griptape_nodes/common/project_templates/default_project_template.py +5 -10
  5. griptape_nodes/common/project_templates/directory.py +5 -5
  6. griptape_nodes/common/project_templates/loader.py +8 -7
  7. griptape_nodes/common/project_templates/project.py +1 -1
  8. griptape_nodes/common/project_templates/situation.py +5 -17
  9. griptape_nodes/common/project_templates/validation.py +3 -3
  10. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +2 -2
  11. griptape_nodes/drivers/storage/local_storage_driver.py +2 -2
  12. griptape_nodes/node_library/workflow_registry.py +1 -1
  13. griptape_nodes/retained_mode/events/project_events.py +208 -93
  14. griptape_nodes/retained_mode/managers/event_manager.py +24 -9
  15. griptape_nodes/retained_mode/managers/library_manager.py +12 -21
  16. griptape_nodes/retained_mode/managers/os_manager.py +54 -6
  17. griptape_nodes/retained_mode/managers/project_manager.py +709 -259
  18. griptape_nodes/retained_mode/managers/static_files_manager.py +1 -5
  19. griptape_nodes/retained_mode/managers/sync_manager.py +4 -1
  20. griptape_nodes/retained_mode/managers/workflow_manager.py +2 -10
  21. griptape_nodes/traits/button.py +2 -1
  22. {griptape_nodes-0.61.0.dist-info → griptape_nodes-0.62.1.dist-info}/METADATA +1 -1
  23. {griptape_nodes-0.61.0.dist-info → griptape_nodes-0.62.1.dist-info}/RECORD +25 -26
  24. {griptape_nodes-0.61.0.dist-info → griptape_nodes-0.62.1.dist-info}/WHEEL +1 -1
  25. griptape_nodes/common/project_templates/defaults/project_template.yml +0 -89
  26. {griptape_nodes-0.61.0.dist-info → griptape_nodes-0.62.1.dist-info}/entry_points.txt +0 -0
@@ -5,87 +5,133 @@ from __future__ import annotations
5
5
  import logging
6
6
  from dataclasses import dataclass
7
7
  from pathlib import Path
8
- from typing import TYPE_CHECKING
8
+ from typing import TYPE_CHECKING, NamedTuple
9
9
 
10
10
  from griptape_nodes.common.macro_parser import (
11
11
  MacroMatchFailure,
12
12
  MacroMatchFailureReason,
13
- MacroParseFailure,
14
- MacroParseFailureReason,
15
13
  MacroResolutionError,
16
14
  MacroResolutionFailureReason,
17
- MacroSyntaxError,
18
15
  ParsedMacro,
19
16
  )
20
17
  from griptape_nodes.common.project_templates import (
21
18
  DEFAULT_PROJECT_TEMPLATE,
19
+ DirectoryDefinition,
22
20
  ProjectTemplate,
23
21
  ProjectValidationInfo,
24
22
  ProjectValidationStatus,
23
+ SituationTemplate,
25
24
  load_project_template_from_yaml,
26
25
  )
27
- from griptape_nodes.retained_mode.events.app_events import AppInitializationComplete # noqa: TC001
26
+ from griptape_nodes.retained_mode.events.app_events import AppInitializationComplete
28
27
  from griptape_nodes.retained_mode.events.os_events import ReadFileRequest, ReadFileResultSuccess
29
28
  from griptape_nodes.retained_mode.events.project_events import (
29
+ AttemptMapAbsolutePathToProjectRequest,
30
+ AttemptMapAbsolutePathToProjectResultFailure,
31
+ AttemptMapAbsolutePathToProjectResultSuccess,
32
+ AttemptMatchPathAgainstMacroRequest,
33
+ AttemptMatchPathAgainstMacroResultFailure,
34
+ AttemptMatchPathAgainstMacroResultSuccess,
30
35
  GetAllSituationsForProjectRequest,
31
36
  GetAllSituationsForProjectResultFailure,
32
37
  GetAllSituationsForProjectResultSuccess,
33
38
  GetCurrentProjectRequest,
39
+ GetCurrentProjectResultFailure,
34
40
  GetCurrentProjectResultSuccess,
35
- GetMacroForSituationRequest,
36
- GetMacroForSituationResultFailure,
37
- GetMacroForSituationResultSuccess,
38
41
  GetPathForMacroRequest,
39
42
  GetPathForMacroResultFailure,
40
43
  GetPathForMacroResultSuccess,
41
44
  GetProjectTemplateRequest,
42
45
  GetProjectTemplateResultFailure,
43
46
  GetProjectTemplateResultSuccess,
44
- GetVariablesForMacroRequest,
45
- GetVariablesForMacroResultFailure,
46
- GetVariablesForMacroResultSuccess,
47
+ GetSituationRequest,
48
+ GetSituationResultFailure,
49
+ GetSituationResultSuccess,
50
+ GetStateForMacroRequest,
51
+ GetStateForMacroResultFailure,
52
+ GetStateForMacroResultSuccess,
53
+ ListProjectTemplatesRequest,
54
+ ListProjectTemplatesResultSuccess,
47
55
  LoadProjectTemplateRequest,
48
56
  LoadProjectTemplateResultFailure,
49
57
  LoadProjectTemplateResultSuccess,
50
- MatchPathAgainstMacroRequest,
51
- MatchPathAgainstMacroResultFailure,
52
- MatchPathAgainstMacroResultSuccess,
53
58
  PathResolutionFailureReason,
59
+ ProjectTemplateInfo,
54
60
  SaveProjectTemplateRequest,
55
61
  SaveProjectTemplateResultFailure,
56
62
  SetCurrentProjectRequest,
57
63
  SetCurrentProjectResultSuccess,
58
- ValidateMacroSyntaxRequest,
59
- ValidateMacroSyntaxResultFailure,
60
- ValidateMacroSyntaxResultSuccess,
61
64
  )
65
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
62
66
 
63
67
  if TYPE_CHECKING:
64
- from griptape_nodes.retained_mode.events.base_events import ResultPayload
65
68
  from griptape_nodes.retained_mode.managers.config_manager import ConfigManager
66
69
  from griptape_nodes.retained_mode.managers.event_manager import EventManager
67
70
  from griptape_nodes.retained_mode.managers.secrets_manager import SecretsManager
68
71
 
69
72
  logger = logging.getLogger("griptape_nodes")
70
73
 
71
- # Synthetic path key for the system default project template
72
- SYSTEM_DEFAULTS_KEY = Path("<system-defaults>")
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"
73
87
 
74
88
 
75
89
  @dataclass(frozen=True)
76
- class SituationMacroKey:
77
- """Key for caching parsed situation schema macros."""
90
+ class BuiltinVariableInfo:
91
+ """Metadata about a builtin variable.
78
92
 
79
- project_path: Path
80
- situation_name: str
93
+ Attributes:
94
+ name: The variable name (e.g., "project_dir")
95
+ is_directory: Whether this variable represents a directory path
96
+ """
81
97
 
98
+ name: str
99
+ is_directory: bool
82
100
 
83
- @dataclass(frozen=True)
84
- class DirectoryMacroKey:
85
- """Key for caching parsed directory schema macros."""
86
101
 
87
- project_path: Path
88
- directory_name: str
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
89
135
 
90
136
 
91
137
  class ProjectManager:
@@ -119,21 +165,16 @@ class ProjectManager:
119
165
  config_manager: ConfigManager instance for accessing configuration
120
166
  secrets_manager: SecretsManager instance for macro resolution
121
167
  """
122
- self.config_manager = config_manager
123
- self.secrets_manager = secrets_manager
124
-
125
- # Track validation status for ALL load attempts (including MISSING/UNUSABLE)
126
- self.registered_template_status: dict[Path, ProjectValidationInfo] = {}
168
+ self._config_manager = config_manager
169
+ self._secrets_manager = secrets_manager
127
170
 
128
- # Cache only successfully loaded templates (GOOD or FLAWED)
129
- self.successful_templates: dict[Path, ProjectTemplate] = {}
171
+ # Consolidated project information storage
172
+ self._successfully_loaded_project_templates: dict[ProjectID, ProjectInfo] = {}
173
+ self._current_project_id: ProjectID | None = None
130
174
 
131
- # Cache parsed macros for performance (avoid re-parsing schemas)
132
- self.parsed_situation_schemas: dict[SituationMacroKey, ParsedMacro] = {}
133
- self.parsed_directory_schemas: dict[DirectoryMacroKey, ParsedMacro] = {}
134
-
135
- # Track which project.yml user has selected
136
- self.current_project_path: Path | None = None
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] = {}
137
178
 
138
179
  # Register event handlers
139
180
  if event_manager is not None:
@@ -144,8 +185,9 @@ class ProjectManager:
144
185
  GetProjectTemplateRequest, self.on_get_project_template_request
145
186
  )
146
187
  event_manager.assign_manager_to_request_type(
147
- GetMacroForSituationRequest, self.on_get_macro_for_situation_request
188
+ ListProjectTemplatesRequest, self.on_list_project_templates_request
148
189
  )
190
+ event_manager.assign_manager_to_request_type(GetSituationRequest, self.on_get_situation_request)
149
191
  event_manager.assign_manager_to_request_type(GetPathForMacroRequest, self.on_get_path_for_macro_request)
150
192
  event_manager.assign_manager_to_request_type(SetCurrentProjectRequest, self.on_set_current_project_request)
151
193
  event_manager.assign_manager_to_request_type(GetCurrentProjectRequest, self.on_get_current_project_request)
@@ -153,30 +195,25 @@ class ProjectManager:
153
195
  SaveProjectTemplateRequest, self.on_save_project_template_request
154
196
  )
155
197
  event_manager.assign_manager_to_request_type(
156
- MatchPathAgainstMacroRequest, self.on_match_path_against_macro_request
157
- )
158
- event_manager.assign_manager_to_request_type(
159
- GetVariablesForMacroRequest, self.on_get_variables_for_macro_request
198
+ AttemptMatchPathAgainstMacroRequest, self.on_match_path_against_macro_request
160
199
  )
200
+ event_manager.assign_manager_to_request_type(GetStateForMacroRequest, self.on_get_state_for_macro_request)
161
201
  event_manager.assign_manager_to_request_type(
162
- ValidateMacroSyntaxRequest, self.on_validate_macro_syntax_request
202
+ GetAllSituationsForProjectRequest, self.on_get_all_situations_for_project_request
163
203
  )
164
204
  event_manager.assign_manager_to_request_type(
165
- GetAllSituationsForProjectRequest, self.on_get_all_situations_for_project_request
205
+ AttemptMapAbsolutePathToProjectRequest, self.on_attempt_map_absolute_path_to_project_request
166
206
  )
167
207
 
168
208
  # Register app initialization listener
169
- # NOTE: This is intentionally commented out to keep ProjectManager inert for code review.
170
- # Uncomment the following lines to enable ProjectManager during app initialization.
171
- # ruff: noqa: ERA001
172
- # event_manager.add_listener_to_app_event(
173
- # AppInitializationComplete,
174
- # self.on_app_initialization_complete,
175
- # )
176
-
177
- # Event handler methods (public)
209
+ event_manager.add_listener_to_app_event(
210
+ AppInitializationComplete,
211
+ self.on_app_initialization_complete,
212
+ )
178
213
 
179
- def on_load_project_template_request(self, request: LoadProjectTemplateRequest) -> ResultPayload:
214
+ def on_load_project_template_request(
215
+ self, request: LoadProjectTemplateRequest
216
+ ) -> LoadProjectTemplateResultSuccess | LoadProjectTemplateResultFailure:
180
217
  """Load user's project.yml and merge with system defaults.
181
218
 
182
219
  Flow:
@@ -187,10 +224,6 @@ class ProjectManager:
187
224
  5. If usable, cache template in successful_templates
188
225
  6. Return LoadProjectTemplateResultSuccess or LoadProjectTemplateResultFailure
189
226
  """
190
- from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
191
-
192
- logger.debug("Loading project template: %s", request.project_path)
193
-
194
227
  read_request = ReadFileRequest(
195
228
  file_path=str(request.project_path),
196
229
  encoding="utf-8",
@@ -200,119 +233,182 @@ class ProjectManager:
200
233
 
201
234
  if read_result.failed():
202
235
  validation = ProjectValidationInfo(status=ProjectValidationStatus.MISSING)
203
- self.registered_template_status[request.project_path] = validation
236
+ self._registered_template_status[request.project_path] = validation
204
237
 
205
238
  return LoadProjectTemplateResultFailure(
206
- project_path=request.project_path,
207
239
  validation=validation,
208
- result_details=f"File not found: {request.project_path}",
240
+ result_details=f"Attempted to load project template from '{request.project_path}'. Failed because file not found",
209
241
  )
210
242
 
211
243
  if not isinstance(read_result, ReadFileResultSuccess):
212
244
  validation = ProjectValidationInfo(status=ProjectValidationStatus.UNUSABLE)
213
- self.registered_template_status[request.project_path] = validation
245
+ self._registered_template_status[request.project_path] = validation
214
246
 
215
247
  return LoadProjectTemplateResultFailure(
216
- project_path=request.project_path,
217
248
  validation=validation,
218
- result_details="Unexpected result type from ReadFileRequest",
249
+ result_details=f"Attempted to load project template from '{request.project_path}'. Failed because file read returned unexpected result type",
219
250
  )
220
251
 
221
252
  yaml_text = read_result.content
222
253
  if not isinstance(yaml_text, str):
223
254
  validation = ProjectValidationInfo(status=ProjectValidationStatus.UNUSABLE)
224
- self.registered_template_status[request.project_path] = validation
255
+ self._registered_template_status[request.project_path] = validation
225
256
 
226
257
  return LoadProjectTemplateResultFailure(
227
- project_path=request.project_path,
228
258
  validation=validation,
229
- result_details="Template must be text, got binary content",
259
+ result_details=f"Attempted to load project template from '{request.project_path}'. Failed because template must be text, got binary content",
230
260
  )
231
261
 
232
262
  validation = ProjectValidationInfo(status=ProjectValidationStatus.GOOD)
233
263
  template = load_project_template_from_yaml(yaml_text, validation)
234
264
 
235
265
  if template is None:
236
- self.registered_template_status[request.project_path] = validation
266
+ self._registered_template_status[request.project_path] = validation
237
267
  return LoadProjectTemplateResultFailure(
238
- project_path=request.project_path,
239
268
  validation=validation,
240
- result_details="Failed to parse YAML template",
269
+ result_details=f"Attempted to load project template from '{request.project_path}'. Failed because YAML could not be parsed",
241
270
  )
242
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
243
282
  if not validation.is_usable():
244
- self.registered_template_status[request.project_path] = validation
283
+ self._registered_template_status[request.project_path] = validation
245
284
  return LoadProjectTemplateResultFailure(
246
- project_path=request.project_path,
247
285
  validation=validation,
248
- result_details=f"Template not usable (status: {validation.status})",
286
+ result_details=f"Attempted to load project template from '{request.project_path}'. Failed because template is not usable (status: {validation.status})",
249
287
  )
250
288
 
251
- logger.debug("Template loaded successfully (status: %s)", validation.status)
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
252
302
 
253
- self.registered_template_status[request.project_path] = validation
254
- self.successful_templates[request.project_path] = template
303
+ # Track validation status for all load attempts (for UI display)
304
+ self._registered_template_status[request.project_path] = validation
255
305
 
256
306
  return LoadProjectTemplateResultSuccess(
257
- project_path=request.project_path,
307
+ project_id=project_id,
258
308
  template=template,
259
309
  validation=validation,
260
310
  result_details=f"Template loaded successfully with status: {validation.status}",
261
311
  )
262
312
 
263
- def on_get_project_template_request(self, request: GetProjectTemplateRequest) -> ResultPayload:
264
- """Get cached template for a workspace path."""
265
- if request.project_path not in self.registered_template_status:
266
- return GetProjectTemplateResultFailure(
267
- result_details=f"Template not loaded yet: {request.project_path}",
268
- )
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)
269
318
 
270
- validation = self.registered_template_status[request.project_path]
271
- template = self.successful_templates.get(request.project_path)
272
-
273
- if template is None:
319
+ if project_info is None:
274
320
  return GetProjectTemplateResultFailure(
275
- result_details=f"Template not usable (status: {validation.status})",
321
+ result_details=f"Attempted to get project template for '{request.project_id}'. Failed because template not loaded yet",
276
322
  )
277
323
 
278
324
  return GetProjectTemplateResultSuccess(
279
- template=template,
280
- validation=validation,
281
- result_details="Project template retrieved from cache",
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}",
282
328
  )
283
329
 
284
- def on_get_macro_for_situation_request(self, request: GetMacroForSituationRequest) -> ResultPayload:
285
- """Get the macro schema for a specific situation.
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.
286
377
 
287
378
  Flow:
288
- 1. Get template from successful_templates
289
- 2. Get situation from template
290
- 3. Return situation's macro schema
379
+ 1. Get current project
380
+ 2. Get template from successful_templates
381
+ 3. Get situation from template
382
+ 4. Return complete SituationTemplate
291
383
  """
292
- logger.debug("Getting macro for situation: %s in project: %s", request.situation_name, request.project_path)
384
+ current_project_request = GetCurrentProjectRequest()
385
+ current_project_result = self.on_get_current_project_request(current_project_request)
293
386
 
294
- template = self.successful_templates.get(request.project_path)
295
- if template is None:
296
- return GetMacroForSituationResultFailure(
297
- result_details=f"Project template not loaded: {request.project_path}",
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",
298
390
  )
299
391
 
392
+ template = current_project_result.project_info.template
393
+
300
394
  situation = template.situations.get(request.situation_name)
301
395
  if situation is None:
302
- return GetMacroForSituationResultFailure(
303
- result_details=f"Situation not found: {request.situation_name}",
396
+ return GetSituationResultFailure(
397
+ result_details=f"Attempted to get situation '{request.situation_name}'. Failed because situation not found",
304
398
  )
305
399
 
306
- return GetMacroForSituationResultSuccess(
307
- macro_schema=situation.schema,
308
- result_details=f"Retrieved schema for situation: {request.situation_name}",
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}",
309
403
  )
310
404
 
311
- def on_get_path_for_macro_request(self, request: GetPathForMacroRequest) -> ResultPayload: # noqa: C901, PLR0911
405
+ def on_get_path_for_macro_request( # noqa: C901, PLR0911, PLR0912, PLR0915
406
+ self, request: GetPathForMacroRequest
407
+ ) -> GetPathForMacroResultSuccess | GetPathForMacroResultFailure:
312
408
  """Resolve ANY macro schema with variables to final Path.
313
409
 
314
410
  Flow:
315
- 1. Parse macro schema with ParsedMacro
411
+ 1. Get current project
316
412
  2. Get variables from ParsedMacro.get_variables()
317
413
  3. For each variable:
318
414
  - If in directories dict → resolve directory, add to resolution bag
@@ -323,48 +419,75 @@ class ProjectManager:
323
419
  5. Resolve macro with complete variable bag
324
420
  6. Return resolved Path
325
421
  """
326
- logger.debug("Resolving macro: %s in project: %s", request.macro_schema, request.project_path)
422
+ current_project_request = GetCurrentProjectRequest()
423
+ current_project_result = self.on_get_current_project_request(current_project_request)
327
424
 
328
- try:
329
- parsed_macro = ParsedMacro(request.macro_schema)
330
- except MacroSyntaxError as e:
425
+ if not isinstance(current_project_result, GetCurrentProjectResultSuccess):
331
426
  return GetPathForMacroResultFailure(
332
427
  failure_reason=PathResolutionFailureReason.MACRO_RESOLUTION_ERROR,
333
- error_details=str(e),
334
- result_details=f"Invalid macro syntax: {e}",
428
+ result_details="Attempted to resolve macro path. Failed because no current project is set or template not loaded",
335
429
  )
336
430
 
337
- template = self.successful_templates.get(request.project_path)
338
- if template is None:
339
- return GetPathForMacroResultFailure(
340
- failure_reason=PathResolutionFailureReason.MACRO_RESOLUTION_ERROR,
341
- error_details="Project template not loaded",
342
- result_details=f"Project template not loaded: {request.project_path}",
343
- )
431
+ project_info = current_project_result.project_info
432
+ template = project_info.template
344
433
 
345
- variable_infos = parsed_macro.get_variables()
434
+ variable_infos = request.parsed_macro.get_variables()
346
435
  directory_names = set(template.directories.keys())
347
436
  user_provided_names = set(request.variables.keys())
348
437
 
438
+ # Check for directory/user variable name conflicts
349
439
  conflicting = directory_names & user_provided_names
350
440
  if conflicting:
351
441
  return GetPathForMacroResultFailure(
352
442
  failure_reason=PathResolutionFailureReason.DIRECTORY_OVERRIDE_ATTEMPTED,
353
- conflicting_variables=sorted(conflicting),
354
- result_details=f"Variables conflict with directory names: {', '.join(sorted(conflicting))}",
443
+ conflicting_variables=conflicting,
444
+ result_details=f"Attempted to resolve macro path. Failed because variables conflict with directory names: {', '.join(sorted(conflicting))}",
355
445
  )
356
446
 
357
447
  resolution_bag: dict[str, str | int] = {}
448
+ disallowed_overrides: set[str] = set()
358
449
 
359
450
  for var_info in variable_infos:
360
451
  var_name = var_info.name
361
452
 
362
453
  if var_name in directory_names:
363
454
  directory_def = template.directories[var_name]
364
- resolution_bag[var_name] = directory_def.path_schema
455
+ resolution_bag[var_name] = directory_def.path_macro
365
456
  elif var_name in user_provided_names:
366
457
  resolution_bag[var_name] = request.variables[var_name]
367
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
+
368
491
  required_vars = {v.name for v in variable_infos if v.is_required}
369
492
  provided_vars = set(resolution_bag.keys())
370
493
  missing = required_vars - provided_vars
@@ -372,19 +495,18 @@ class ProjectManager:
372
495
  if missing:
373
496
  return GetPathForMacroResultFailure(
374
497
  failure_reason=PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES,
375
- missing_variables=sorted(missing),
376
- result_details=f"Missing required variables: {', '.join(sorted(missing))}",
498
+ missing_variables=missing,
499
+ result_details=f"Attempted to resolve macro path. Failed because missing required variables: {', '.join(sorted(missing))}",
377
500
  )
378
501
 
379
- if self.secrets_manager is None:
502
+ if self._secrets_manager is None:
380
503
  return GetPathForMacroResultFailure(
381
504
  failure_reason=PathResolutionFailureReason.MACRO_RESOLUTION_ERROR,
382
- error_details="SecretsManager not available",
383
- result_details="SecretsManager not available",
505
+ result_details="Attempted to resolve macro path. Failed because SecretsManager is not available",
384
506
  )
385
507
 
386
508
  try:
387
- resolved_string = parsed_macro.resolve(resolution_bag, self.secrets_manager)
509
+ resolved_string = request.parsed_macro.resolve(resolution_bag, self._secrets_manager)
388
510
  except MacroResolutionError as e:
389
511
  if e.failure_reason == MacroResolutionFailureReason.MISSING_REQUIRED_VARIABLES:
390
512
  path_failure_reason = PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES
@@ -394,38 +516,57 @@ class ProjectManager:
394
516
  return GetPathForMacroResultFailure(
395
517
  failure_reason=path_failure_reason,
396
518
  missing_variables=e.missing_variables,
397
- error_details=str(e),
398
- result_details=f"Macro resolution failed: {e}",
519
+ result_details=f"Attempted to resolve macro path. Failed because macro resolution error: {e}",
399
520
  )
400
521
 
401
522
  resolved_path = Path(resolved_string)
402
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
+
403
530
  return GetPathForMacroResultSuccess(
404
531
  resolved_path=resolved_path,
405
- result_details=f"Resolved to: {resolved_path}",
532
+ absolute_path=absolute_path,
533
+ result_details=f"Successfully resolved macro path. Result: {resolved_path}",
406
534
  )
407
535
 
408
- def on_set_current_project_request(self, request: SetCurrentProjectRequest) -> ResultPayload:
409
- """Set which project.yml user has selected."""
410
- self.current_project_path = request.project_path
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
411
539
 
412
- if request.project_path is None:
413
- logger.info("Current project set to: No Project")
414
- else:
415
- logger.info("Current project set to: %s", request.project_path)
540
+ if request.project_id is None:
541
+ return SetCurrentProjectResultSuccess(
542
+ result_details="Successfully set current project. No project selected",
543
+ )
416
544
 
417
545
  return SetCurrentProjectResultSuccess(
418
- result_details="Current project set successfully",
546
+ result_details=f"Successfully set current project. ID: {request.project_id}",
419
547
  )
420
548
 
421
- def on_get_current_project_request(self, _request: GetCurrentProjectRequest) -> ResultPayload:
422
- """Get currently selected project path."""
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
+
423
564
  return GetCurrentProjectResultSuccess(
424
- project_path=self.current_project_path,
425
- result_details="Current project retrieved successfully",
565
+ project_info=project_info,
566
+ result_details=f"Successfully retrieved current project. ID: {self._current_project_id}",
426
567
  )
427
568
 
428
- def on_save_project_template_request(self, request: SaveProjectTemplateRequest) -> ResultPayload:
569
+ def on_save_project_template_request(self, request: SaveProjectTemplateRequest) -> SaveProjectTemplateResultFailure:
429
570
  """Save user customizations to project.yml.
430
571
 
431
572
  Flow:
@@ -436,128 +577,126 @@ class ProjectManager:
436
577
 
437
578
  TODO: Implement saving logic when template system merges
438
579
  """
439
- logger.debug("Saving project template: %s", request.project_path)
440
-
441
580
  return SaveProjectTemplateResultFailure(
442
- project_path=request.project_path,
443
- result_details="Template saving not yet implemented (stub)",
581
+ result_details=f"Attempted to save project template to '{request.project_path}'. Failed because template saving not yet implemented",
444
582
  )
445
583
 
446
- def on_match_path_against_macro_request(self, request: MatchPathAgainstMacroRequest) -> ResultPayload:
447
- """Check if a path matches a macro schema and extract variables.
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.
448
588
 
449
589
  Flow:
450
- 1. Parse macro schema into ParsedMacro
590
+ 1. Check secrets manager is available (failure = true error)
451
591
  2. Call ParsedMacro.extract_variables() with path and known variables
452
- 3. If match succeeds, return extracted variables
453
- 4. If match fails, return MacroMatchFailure with details
592
+ 3. If match succeeds, return success with extracted_variables
593
+ 4. If match fails, return success with match_failure (not an error)
454
594
  """
455
- logger.debug("Matching path against macro: %s", request.macro_schema)
456
-
457
- if self.secrets_manager is None:
458
- return MatchPathAgainstMacroResultFailure(
459
- match_failure=MacroMatchFailure(
460
- failure_reason=MacroMatchFailureReason.INVALID_MACRO_SYNTAX,
461
- expected_pattern=request.macro_schema,
462
- known_variables_used=request.known_variables,
463
- error_details="SecretsManager not available",
464
- ),
465
- result_details="SecretsManager not available for macro matching",
466
- )
467
-
468
- try:
469
- parsed_macro = ParsedMacro(request.macro_schema)
470
- except MacroSyntaxError as err:
471
- return MatchPathAgainstMacroResultFailure(
472
- match_failure=MacroMatchFailure(
473
- failure_reason=MacroMatchFailureReason.INVALID_MACRO_SYNTAX,
474
- expected_pattern=request.macro_schema,
475
- known_variables_used=request.known_variables,
476
- error_details=str(err),
477
- ),
478
- result_details=f"Invalid macro syntax: {err}",
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",
479
598
  )
480
599
 
481
- extracted = parsed_macro.extract_variables(
600
+ extracted = request.parsed_macro.extract_variables(
482
601
  request.file_path,
483
602
  request.known_variables,
484
- self.secrets_manager,
603
+ self._secrets_manager,
485
604
  )
486
605
 
487
606
  if extracted is None:
488
- return MatchPathAgainstMacroResultFailure(
607
+ # Pattern didn't match - this is a normal outcome, not an error
608
+ return AttemptMatchPathAgainstMacroResultSuccess(
609
+ extracted_variables=None,
489
610
  match_failure=MacroMatchFailure(
490
611
  failure_reason=MacroMatchFailureReason.STATIC_TEXT_MISMATCH,
491
- expected_pattern=request.macro_schema,
612
+ expected_pattern=request.parsed_macro.template,
492
613
  known_variables_used=request.known_variables,
493
614
  error_details=f"Path '{request.file_path}' does not match macro pattern",
494
615
  ),
495
- result_details="Path does not match macro pattern",
616
+ result_details=f"Attempted to match path '{request.file_path}' against macro '{request.parsed_macro.template}'. Pattern did not match",
496
617
  )
497
618
 
498
- return MatchPathAgainstMacroResultSuccess(
619
+ # Pattern matched successfully
620
+ return AttemptMatchPathAgainstMacroResultSuccess(
499
621
  extracted_variables=extracted,
500
- result_details="Successfully matched path against macro",
622
+ match_failure=None,
623
+ result_details=f"Successfully matched path '{request.file_path}' against macro '{request.parsed_macro.template}'. Extracted {len(extracted)} variables",
501
624
  )
502
625
 
503
- def on_get_variables_for_macro_request(self, request: GetVariablesForMacroRequest) -> ResultPayload:
504
- """Get list of all variables in a macro schema.
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.
505
630
 
506
631
  Flow:
507
- 1. Parse macro schema into ParsedMacro
508
- 2. Call ParsedMacro.get_variables() to extract variable metadata
509
- 3. Return list of VariableInfo
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
510
643
  """
511
- logger.debug("Getting variables for macro: %s", request.macro_schema)
644
+ current_project_request = GetCurrentProjectRequest()
645
+ current_project_result = self.on_get_current_project_request(current_project_request)
512
646
 
513
- try:
514
- parsed_macro = ParsedMacro(request.macro_schema)
515
- except MacroSyntaxError as err:
516
- return GetVariablesForMacroResultFailure(
517
- parse_failure=MacroParseFailure(
518
- failure_reason=err.failure_reason or MacroParseFailureReason.UNEXPECTED_SEGMENT_TYPE,
519
- error_position=err.error_position,
520
- error_details=str(err),
521
- ),
522
- result_details=f"Failed to parse macro: {err}",
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",
523
650
  )
524
651
 
525
- variables = parsed_macro.get_variables()
526
-
527
- return GetVariablesForMacroResultSuccess(
528
- variables=variables,
529
- result_details=f"Found {len(variables)} variables in macro",
530
- )
531
-
532
- def on_validate_macro_syntax_request(self, request: ValidateMacroSyntaxRequest) -> ResultPayload:
533
- """Validate a macro schema string for syntax errors.
652
+ project_info = current_project_result.project_info
653
+ template = project_info.template
534
654
 
535
- Flow:
536
- 1. Try to parse macro schema with ParsedMacro()
537
- 2. If successful, return variables found and any warnings
538
- 3. If syntax error, return MacroParseFailure with details
539
- """
540
- logger.debug("Validating macro syntax: %s", request.macro_schema)
655
+ all_variables = request.parsed_macro.get_variables()
656
+ directory_names = set(template.directories.keys())
657
+ user_provided_names = set(request.variables.keys())
541
658
 
542
- try:
543
- parsed_macro = ParsedMacro(request.macro_schema)
544
- except MacroSyntaxError as err:
545
- return ValidateMacroSyntaxResultFailure(
546
- parse_failure=MacroParseFailure(
547
- failure_reason=err.failure_reason or MacroParseFailureReason.UNEXPECTED_SEGMENT_TYPE,
548
- error_position=err.error_position,
549
- error_details=str(err),
550
- ),
551
- partial_variables=[],
552
- result_details=f"Syntax validation failed: {err}",
553
- )
659
+ satisfied_variables: set[str] = set()
660
+ missing_required_variables: set[str] = set()
661
+ conflicting_variables: set[str] = set()
554
662
 
555
- variables = parsed_macro.get_variables()
663
+ for var_info in all_variables:
664
+ var_name = var_info.name
556
665
 
557
- return ValidateMacroSyntaxResultSuccess(
558
- variables=variables,
559
- warnings=[],
560
- result_details=f"Macro syntax is valid with {len(variables)} variables",
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",
561
700
  )
562
701
 
563
702
  async def on_app_initialization_complete(self, _payload: AppInitializationComplete) -> None:
@@ -565,12 +704,10 @@ class ProjectManager:
565
704
 
566
705
  Called by EventManager after all libraries are loaded.
567
706
  """
568
- logger.debug("ProjectManager: Loading system default project template")
569
-
570
707
  self._load_system_defaults()
571
708
 
572
709
  # Set as current project (using synthetic key for system defaults)
573
- set_request = SetCurrentProjectRequest(project_path=SYSTEM_DEFAULTS_KEY)
710
+ set_request = SetCurrentProjectRequest(project_id=SYSTEM_DEFAULTS_KEY)
574
711
  result = self.on_set_current_project_request(set_request)
575
712
 
576
713
  if result.failed():
@@ -578,26 +715,310 @@ class ProjectManager:
578
715
  else:
579
716
  logger.debug("Successfully loaded default project template")
580
717
 
581
- def on_get_all_situations_for_project_request(self, request: GetAllSituationsForProjectRequest) -> ResultPayload:
582
- """Get all situation names and schemas from a project template."""
583
- logger.debug("Getting all situations for project: %s", request.project_path)
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)
584
724
 
585
- template_info = self.registered_template_status.get(request.project_path)
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
+ )
586
729
 
587
- if template_info is None:
588
- self._load_system_defaults()
589
- template_info = self.registered_template_status.get(request.project_path)
730
+ template = current_project_result.project_info.template
731
+ situations = {situation_name: situation.macro for situation_name, situation in template.situations.items()}
590
732
 
591
- if template_info is None or template_info.status != ProjectValidationStatus.GOOD:
592
- return GetAllSituationsForProjectResultFailure(result_details="Project template not available or invalid")
733
+ return GetAllSituationsForProjectResultSuccess(
734
+ situations=situations,
735
+ result_details=f"Successfully retrieved all situations. Found {len(situations)} situations",
736
+ )
593
737
 
594
- template = self.successful_templates[request.project_path]
595
- situations = {situation_name: situation.schema for situation_name, situation in template.situations.items()}
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.
596
742
 
597
- return GetAllSituationsForProjectResultSuccess(
598
- situations=situations, result_details=f"Found {len(situations)} situations"
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}'",
599
792
  )
600
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
+
601
1022
  # Private helper methods
602
1023
 
603
1024
  def _load_system_defaults(self) -> None:
@@ -611,7 +1032,36 @@ class ProjectManager:
611
1032
  # Create validation info to track that defaults were loaded
612
1033
  validation = ProjectValidationInfo(status=ProjectValidationStatus.GOOD)
613
1034
 
614
- logger.debug("System defaults loaded successfully")
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
+ )
615
1063
 
616
- self.registered_template_status[SYSTEM_DEFAULTS_KEY] = validation
617
- self.successful_templates[SYSTEM_DEFAULTS_KEY] = DEFAULT_PROJECT_TEMPLATE
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")