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,67 @@
1
+ """Directory definition for logical project directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from pydantic import BaseModel, Field, ValidationError
8
+
9
+ if TYPE_CHECKING:
10
+ from griptape_nodes.common.project_templates.loader import YAMLLineInfo
11
+ from griptape_nodes.common.project_templates.validation import ProjectValidationInfo
12
+
13
+
14
+ class DirectoryDefinition(BaseModel):
15
+ """Definition of a logical directory in the project."""
16
+
17
+ name: str = Field(description="Logical name (e.g., 'inputs', 'outputs')")
18
+ path_macro: str = Field(description="Path string (may contain macros/env vars)")
19
+
20
+ @staticmethod
21
+ def merge(
22
+ base: DirectoryDefinition,
23
+ overlay_data: dict[str, Any],
24
+ field_path: str,
25
+ validation_info: ProjectValidationInfo,
26
+ line_info: YAMLLineInfo,
27
+ ) -> DirectoryDefinition:
28
+ """Merge overlay fields onto base directory.
29
+
30
+ Field-level merge behavior:
31
+ - path_macro: Use overlay if present, else base
32
+
33
+ Args:
34
+ base: Complete base directory
35
+ overlay_data: Partial directory dict from overlay
36
+ field_path: Path for validation errors (e.g., "directories.inputs")
37
+ validation_info: Shared validation info
38
+ line_info: Line tracking from overlay
39
+
40
+ Returns:
41
+ New merged DirectoryDefinition
42
+ """
43
+ # Start with base fields
44
+ merged_data = {"name": base.name, "path_macro": base.path_macro}
45
+
46
+ # Apply overlay if present
47
+ if "path_macro" in overlay_data:
48
+ merged_data["path_macro"] = overlay_data["path_macro"]
49
+
50
+ try:
51
+ return DirectoryDefinition.model_validate(merged_data)
52
+ except ValidationError as e:
53
+ # Convert Pydantic validation errors to our validation_info format
54
+ for error in e.errors():
55
+ error_field_path = ".".join(str(loc) for loc in error["loc"])
56
+ full_field_path = f"{field_path}.{error_field_path}"
57
+ message = error["msg"]
58
+ line_number = line_info.get_line(full_field_path)
59
+
60
+ validation_info.add_error(
61
+ field_path=full_field_path,
62
+ message=message,
63
+ line_number=line_number,
64
+ )
65
+
66
+ # Return base on validation error (fault-tolerant)
67
+ return base
@@ -0,0 +1,342 @@
1
+ """YAML loading with line number tracking for project templates.
2
+
3
+ Note: This module only handles YAML parsing. File I/O operations should be
4
+ handled by ProjectManager using ReadFileRequest/WriteFileRequest to ensure
5
+ proper long path handling on Windows and consistent error handling.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import TYPE_CHECKING, Any, NamedTuple
12
+
13
+ from pydantic import ValidationError
14
+ from ruamel.yaml import YAML
15
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
16
+ from ruamel.yaml.error import YAMLError
17
+
18
+ if TYPE_CHECKING:
19
+ from griptape_nodes.common.project_templates.project import ProjectTemplate
20
+ from griptape_nodes.common.project_templates.validation import ProjectValidationInfo
21
+
22
+ # Field name constants
23
+ FIELD_NAME = "name"
24
+ FIELD_SITUATIONS = "situations"
25
+ FIELD_DIRECTORIES = "directories"
26
+ FIELD_PROJECT_TEMPLATE_SCHEMA_VERSION = "project_template_schema_version"
27
+ FIELD_ENVIRONMENT = "environment"
28
+ FIELD_DESCRIPTION = "description"
29
+
30
+ # Special constants
31
+ ROOT_FIELD_PATH = "<root>"
32
+ DEFAULT_SCHEMA_VERSION = "0.1.0"
33
+ DEFAULT_PROJECT_NAME = "Invalid Project"
34
+
35
+
36
+ @dataclass
37
+ class YAMLLineInfo:
38
+ """Line number information for YAML fields."""
39
+
40
+ field_path_to_line: dict[str, int] = field(default_factory=dict)
41
+
42
+ def get_line(self, field_path: str) -> int | None:
43
+ """Get line number for a field path, returns None if not tracked."""
44
+ return self.field_path_to_line.get(field_path)
45
+
46
+ def add_mapping(self, field_path: str, line_number: int) -> None:
47
+ """Add a field path to line number mapping."""
48
+ self.field_path_to_line[field_path] = line_number
49
+
50
+
51
+ class YAMLParseResult(NamedTuple):
52
+ """Result of parsing YAML with line tracking.
53
+
54
+ Contains the parsed YAML data and line number tracking information.
55
+ """
56
+
57
+ data: dict[str, Any]
58
+ line_info: YAMLLineInfo
59
+
60
+
61
+ class ProjectOverlayData(NamedTuple):
62
+ """Partially validated project template data for merging.
63
+
64
+ Contains raw dicts for situations/directories with basic structural validation.
65
+ Line tracking preserved for error reporting during merge.
66
+ Used as overlay input to ProjectTemplate.merge().
67
+ """
68
+
69
+ name: str
70
+ project_template_schema_version: str
71
+ situations: dict[str, dict[str, Any]] # situation_name -> raw dict
72
+ directories: dict[str, dict[str, Any]] # directory_name -> raw dict
73
+ environment: dict[str, str]
74
+ description: str | None
75
+ line_info: YAMLLineInfo
76
+
77
+
78
+ def load_yaml_with_line_tracking(yaml_text: str) -> YAMLParseResult:
79
+ """Load YAML preserving line numbers and comments.
80
+
81
+ Uses ruamel.yaml to parse while tracking line numbers for each field.
82
+
83
+ Returns:
84
+ - Parsed YAML data as dict
85
+ - Line number tracking info
86
+
87
+ Raises:
88
+ YAMLError: If YAML syntax is invalid (unclosed quotes, invalid structure, etc.)
89
+ """
90
+ yaml = YAML()
91
+ yaml.preserve_quotes = True
92
+ yaml.default_flow_style = False
93
+
94
+ # Parse YAML with line tracking
95
+ # ruamel.yaml returns CommentedMap/CommentedSeq objects with line info
96
+ data = yaml.load(yaml_text)
97
+
98
+ if not isinstance(data, dict):
99
+ msg = f"Expected YAML root to be a mapping (dict), got {type(data).__name__}"
100
+ raise YAMLError(msg)
101
+
102
+ # Build line number mapping
103
+ line_info = YAMLLineInfo()
104
+
105
+ def track_lines(obj: CommentedMap | CommentedSeq, path: str) -> None:
106
+ """Recursively track line numbers for YAML objects.
107
+
108
+ Args:
109
+ obj: CommentedMap or CommentedSeq from ruamel.yaml (has lc attribute)
110
+ path: Current field path for tracking
111
+
112
+ Note: Only CommentedMap/CommentedSeq objects have line tracking.
113
+ Plain dicts/lists and scalars don't have lc attributes.
114
+ """
115
+ # Track line number for this object
116
+ line = obj.lc.line + 1 # Convert to 1-indexed
117
+ line_info.add_mapping(path, line)
118
+
119
+ # Recurse into children
120
+ if isinstance(obj, CommentedMap):
121
+ for key, value in obj.items():
122
+ child_path = f"{path}.{key}" if path else key
123
+ if isinstance(value, CommentedMap | CommentedSeq):
124
+ track_lines(value, child_path)
125
+ elif isinstance(obj, CommentedSeq):
126
+ for i, item in enumerate(obj):
127
+ child_path = f"{path}[{i}]"
128
+ if isinstance(item, CommentedMap | CommentedSeq):
129
+ track_lines(item, child_path)
130
+
131
+ # At runtime, data is a CommentedMap from ruamel.yaml
132
+ if isinstance(data, CommentedMap):
133
+ track_lines(data, "")
134
+
135
+ return YAMLParseResult(data=data, line_info=line_info)
136
+
137
+
138
+ def load_project_template_from_yaml( # noqa: C901
139
+ yaml_text: str,
140
+ validation_info: ProjectValidationInfo,
141
+ ) -> ProjectTemplate | None:
142
+ """Parse project.yml text into ProjectTemplate.
143
+
144
+ Two-pass approach:
145
+ 1. Load raw YAML with line tracking (may raise YAMLError)
146
+ 2. Build ProjectTemplate via Pydantic validation, collecting validation problems
147
+
148
+ Args:
149
+ yaml_text: YAML text to parse
150
+ validation_info: Validation info to populate (caller-owned, will be mutated)
151
+
152
+ Returns:
153
+ ProjectTemplate on success, None if fatal errors prevent construction
154
+ """
155
+ # Lazy import required: circular dependency between this module and project.py
156
+ # project.py imports ProjectOverlayData from this file, and we need ProjectTemplate from project.py
157
+ from griptape_nodes.common.project_templates.project import ProjectTemplate
158
+
159
+ # Pass 1: Load YAML with line tracking
160
+ try:
161
+ result = load_yaml_with_line_tracking(yaml_text)
162
+ except YAMLError as e:
163
+ # YAML syntax error - cannot proceed
164
+ validation_info.add_error(
165
+ field_path=ROOT_FIELD_PATH,
166
+ message=f"YAML syntax error: {e}",
167
+ line_number=None,
168
+ )
169
+ return None
170
+
171
+ # Pass 2: Build ProjectTemplate with Pydantic validation
172
+ data = result.data
173
+ line_info = result.line_info
174
+
175
+ # Add names to situations and directories before validation
176
+ if FIELD_SITUATIONS in data and isinstance(data[FIELD_SITUATIONS], dict):
177
+ for sit_name, sit_data in data[FIELD_SITUATIONS].items():
178
+ if isinstance(sit_data, dict):
179
+ sit_data[FIELD_NAME] = sit_name
180
+
181
+ if FIELD_DIRECTORIES in data and isinstance(data[FIELD_DIRECTORIES], dict):
182
+ for dir_name, dir_data in data[FIELD_DIRECTORIES].items():
183
+ if isinstance(dir_data, dict):
184
+ dir_data[FIELD_NAME] = dir_name
185
+
186
+ try:
187
+ template = ProjectTemplate.model_validate(data)
188
+ except ValidationError as e:
189
+ # Convert Pydantic validation errors to our validation_info format
190
+ for error in e.errors():
191
+ # Build field path from error location
192
+ field_path = ".".join(str(loc) for loc in error["loc"])
193
+ message = error["msg"]
194
+
195
+ # Try to get line number from our line tracking
196
+ line_number = line_info.get_line(field_path)
197
+
198
+ validation_info.add_error(
199
+ field_path=field_path,
200
+ message=message,
201
+ line_number=line_number,
202
+ )
203
+
204
+ return None
205
+ else:
206
+ # Add warnings for schema version mismatches
207
+ if template.project_template_schema_version != ProjectTemplate.LATEST_SCHEMA_VERSION:
208
+ message = (
209
+ f"Schema version '{template.project_template_schema_version}' "
210
+ f"differs from latest '{ProjectTemplate.LATEST_SCHEMA_VERSION}'"
211
+ )
212
+ validation_info.add_warning(
213
+ field_path=FIELD_PROJECT_TEMPLATE_SCHEMA_VERSION,
214
+ message=message,
215
+ line_number=line_info.get_line(FIELD_PROJECT_TEMPLATE_SCHEMA_VERSION),
216
+ )
217
+
218
+ return template
219
+
220
+
221
+ def load_partial_project_template(
222
+ yaml_text: str,
223
+ validation_info: ProjectValidationInfo,
224
+ ) -> ProjectOverlayData | None:
225
+ """Load project template overlay for merging without full construction.
226
+
227
+ Performs minimal structural validation:
228
+ - YAML syntax
229
+ - Required top-level fields (name, project_template_schema_version)
230
+ - Basic type checking (situations is dict, directories is dict, etc.)
231
+
232
+ Does NOT:
233
+ - Construct full SituationTemplate/DirectoryDefinition objects
234
+ - Validate situation macros or policy values
235
+ - Check directory references
236
+
237
+ Use this for loading user overlay templates before merge.
238
+ After merge, full validation happens during object construction.
239
+
240
+ Args:
241
+ yaml_text: YAML text to parse
242
+ validation_info: Validation info to populate (caller-owned, will be mutated)
243
+
244
+ Returns:
245
+ ProjectOverlayData on success, None if fatal errors prevent construction
246
+ """
247
+ # Parse YAML with line tracking
248
+ try:
249
+ result = load_yaml_with_line_tracking(yaml_text)
250
+ data = result.data
251
+ line_info = result.line_info
252
+ except YAMLError as e:
253
+ validation_info.add_error(
254
+ field_path=ROOT_FIELD_PATH,
255
+ message=f"YAML syntax error: {e}",
256
+ line_number=None,
257
+ )
258
+ return None
259
+
260
+ # Validate required field: name
261
+ name = data.get(FIELD_NAME)
262
+ if name is None:
263
+ validation_info.add_error(
264
+ field_path=FIELD_NAME,
265
+ message=f"Required field '{FIELD_NAME}' missing",
266
+ line_number=line_info.get_line(FIELD_NAME),
267
+ )
268
+ name = DEFAULT_PROJECT_NAME
269
+ elif not isinstance(name, str):
270
+ validation_info.add_error(
271
+ field_path=FIELD_NAME,
272
+ message=f"Field '{FIELD_NAME}' must be string, got {type(name).__name__}",
273
+ line_number=line_info.get_line(FIELD_NAME),
274
+ )
275
+ name = DEFAULT_PROJECT_NAME
276
+
277
+ # Validate required field: project_template_schema_version
278
+ schema_version = data.get(FIELD_PROJECT_TEMPLATE_SCHEMA_VERSION)
279
+ if schema_version is None:
280
+ validation_info.add_error(
281
+ field_path=FIELD_PROJECT_TEMPLATE_SCHEMA_VERSION,
282
+ message=f"Required field '{FIELD_PROJECT_TEMPLATE_SCHEMA_VERSION}' missing",
283
+ line_number=line_info.get_line(FIELD_PROJECT_TEMPLATE_SCHEMA_VERSION),
284
+ )
285
+ schema_version = DEFAULT_SCHEMA_VERSION
286
+ elif not isinstance(schema_version, str):
287
+ validation_info.add_error(
288
+ field_path=FIELD_PROJECT_TEMPLATE_SCHEMA_VERSION,
289
+ message=f"Must be string, got {type(schema_version).__name__}",
290
+ line_number=line_info.get_line(FIELD_PROJECT_TEMPLATE_SCHEMA_VERSION),
291
+ )
292
+ schema_version = DEFAULT_SCHEMA_VERSION
293
+
294
+ # Optional field: situations (default to empty dict)
295
+ situations = data.get(FIELD_SITUATIONS, {})
296
+ if not isinstance(situations, dict):
297
+ validation_info.add_error(
298
+ field_path=FIELD_SITUATIONS,
299
+ message=f"Must be dict, got {type(situations).__name__}",
300
+ line_number=line_info.get_line(FIELD_SITUATIONS),
301
+ )
302
+ situations = {}
303
+
304
+ # Optional field: directories (default to empty dict)
305
+ directories = data.get(FIELD_DIRECTORIES, {})
306
+ if not isinstance(directories, dict):
307
+ validation_info.add_error(
308
+ field_path=FIELD_DIRECTORIES,
309
+ message=f"Must be dict, got {type(directories).__name__}",
310
+ line_number=line_info.get_line(FIELD_DIRECTORIES),
311
+ )
312
+ directories = {}
313
+
314
+ # Optional field: environment (default to empty dict)
315
+ environment = data.get(FIELD_ENVIRONMENT, {})
316
+ if not isinstance(environment, dict):
317
+ validation_info.add_error(
318
+ field_path=FIELD_ENVIRONMENT,
319
+ message=f"Must be dict, got {type(environment).__name__}",
320
+ line_number=line_info.get_line(FIELD_ENVIRONMENT),
321
+ )
322
+ environment = {}
323
+
324
+ # Optional field: description
325
+ description = data.get(FIELD_DESCRIPTION)
326
+ if description is not None and not isinstance(description, str):
327
+ validation_info.add_error(
328
+ field_path=FIELD_DESCRIPTION,
329
+ message=f"Must be string, got {type(description).__name__}",
330
+ line_number=line_info.get_line(FIELD_DESCRIPTION),
331
+ )
332
+ description = None
333
+
334
+ return ProjectOverlayData(
335
+ name=name,
336
+ project_template_schema_version=schema_version,
337
+ situations=situations,
338
+ directories=directories,
339
+ environment=environment,
340
+ description=description,
341
+ line_info=line_info,
342
+ )
@@ -0,0 +1,252 @@
1
+ """Project template main class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from typing import TYPE_CHECKING, ClassVar
7
+
8
+ from pydantic import BaseModel, Field, ValidationError
9
+ from ruamel.yaml import YAML
10
+
11
+ from griptape_nodes.common.project_templates.directory import DirectoryDefinition
12
+ from griptape_nodes.common.project_templates.situation import SituationTemplate
13
+ from griptape_nodes.common.project_templates.validation import (
14
+ ProjectOverrideAction,
15
+ ProjectOverrideCategory,
16
+ ProjectValidationInfo,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from griptape_nodes.common.project_templates.loader import ProjectOverlayData
21
+
22
+
23
+ class ProjectTemplate(BaseModel):
24
+ """Complete project template loaded from project.yml."""
25
+
26
+ LATEST_SCHEMA_VERSION: ClassVar[str] = "0.1.0"
27
+
28
+ project_template_schema_version: str = Field(description="Schema version for the project template")
29
+ name: str = Field(description="Name of the project")
30
+ situations: dict[str, SituationTemplate] = Field(description="Situation templates (situation_name -> template)")
31
+ directories: dict[str, DirectoryDefinition] = Field(
32
+ description="Directory definitions (logical_name -> definition)",
33
+ )
34
+ environment: dict[str, str] = Field(default_factory=dict, description="Custom environment variables")
35
+ description: str | None = Field(default=None, description="Description of the project")
36
+
37
+ def get_situation(self, situation_name: str) -> SituationTemplate | None:
38
+ """Get a situation by name, returns None if not found."""
39
+ return self.situations.get(situation_name)
40
+
41
+ def get_directory(self, directory_name: str) -> DirectoryDefinition | None:
42
+ """Get a directory definition by logical name."""
43
+ return self.directories.get(directory_name)
44
+
45
+ def to_yaml(self, *, include_comments: bool = True) -> str:
46
+ """Export project template to YAML string.
47
+
48
+ If include_comments=True, adds helpful comments explaining each section.
49
+ """
50
+ yaml = YAML()
51
+ yaml.preserve_quotes = True
52
+ yaml.default_flow_style = False
53
+
54
+ data = self.model_dump(mode="json", exclude_none=True)
55
+
56
+ # Convert to YAML string
57
+ stream = io.StringIO()
58
+ yaml.dump(data, stream)
59
+ yaml_text = stream.getvalue()
60
+
61
+ if include_comments:
62
+ # Add helpful header comment
63
+ header = (
64
+ "# Project Template\n"
65
+ f"# Version: {self.project_template_schema_version}\n"
66
+ "#\n"
67
+ "# This file defines how files are organized and saved in your project.\n"
68
+ "# See documentation for details on customizing situations and directories.\n\n"
69
+ )
70
+ yaml_text = header + yaml_text
71
+
72
+ return yaml_text
73
+
74
+ @staticmethod
75
+ def merge( # noqa: C901, PLR0912
76
+ base: ProjectTemplate,
77
+ overlay: ProjectOverlayData,
78
+ validation_info: ProjectValidationInfo,
79
+ ) -> ProjectTemplate:
80
+ """Merge overlay data on top of base template.
81
+
82
+ Merge behavior:
83
+ - name: From overlay (required)
84
+ - description: From overlay if present, else base
85
+ - project_template_schema_version: From overlay (required)
86
+ - situations: Dict merge with field-level merging for conflicts
87
+ - directories: Dict merge with field-level merging for conflicts
88
+ - environment: Dict merge (overlay values override base)
89
+
90
+ Override tracking (non-status-affecting):
91
+ - Metadata: name (always MODIFIED), description (if different)
92
+ - Situations: MODIFIED if exists in base, ADDED if new
93
+ - Directories: MODIFIED if exists in base, ADDED if new
94
+ - Environment: MODIFIED if exists in base, ADDED if new
95
+
96
+ Note: Schema version compatibility should be checked by caller (ProjectManager)
97
+ before calling merge. This method does not validate version compatibility.
98
+
99
+ Args:
100
+ base: Fully constructed base template (e.g., system defaults)
101
+ overlay: Partially validated overlay data with raw dicts
102
+ validation_info: Fresh ProjectValidationInfo for tracking overrides and errors
103
+
104
+ Returns:
105
+ New fully constructed merged ProjectTemplate with validation_info
106
+ """
107
+ # Track metadata overrides
108
+ validation_info.add_override(
109
+ category=ProjectOverrideCategory.METADATA,
110
+ name="name",
111
+ action=ProjectOverrideAction.MODIFIED,
112
+ )
113
+
114
+ if overlay.description is not None and overlay.description != base.description:
115
+ validation_info.add_override(
116
+ category=ProjectOverrideCategory.METADATA,
117
+ name="description",
118
+ action=ProjectOverrideAction.MODIFIED,
119
+ )
120
+
121
+ # Merge situations
122
+ merged_situations: dict[str, SituationTemplate] = {}
123
+
124
+ # Start with all base situations
125
+ for sit_name, base_sit in base.situations.items():
126
+ if sit_name in overlay.situations:
127
+ # Field-level merge
128
+ merged_sit = SituationTemplate.merge(
129
+ base=base_sit,
130
+ overlay_data=overlay.situations[sit_name],
131
+ field_path=f"situations.{sit_name}",
132
+ validation_info=validation_info,
133
+ line_info=overlay.line_info,
134
+ )
135
+ merged_situations[sit_name] = merged_sit
136
+
137
+ validation_info.add_override(
138
+ category=ProjectOverrideCategory.SITUATION,
139
+ name=sit_name,
140
+ action=ProjectOverrideAction.MODIFIED,
141
+ )
142
+ else:
143
+ # Inherit from base
144
+ merged_situations[sit_name] = base_sit
145
+
146
+ # Add new situations from overlay
147
+ for sit_name, sit_data in overlay.situations.items():
148
+ if sit_name not in base.situations:
149
+ # New situation - construct from scratch
150
+ # Add name to dict for model_validate
151
+ sit_data_with_name = {"name": sit_name, **sit_data}
152
+
153
+ try:
154
+ new_sit = SituationTemplate.model_validate(sit_data_with_name)
155
+ merged_situations[sit_name] = new_sit
156
+
157
+ validation_info.add_override(
158
+ category=ProjectOverrideCategory.SITUATION,
159
+ name=sit_name,
160
+ action=ProjectOverrideAction.ADDED,
161
+ )
162
+ except ValidationError as e:
163
+ # Convert Pydantic validation errors
164
+ for error in e.errors():
165
+ error_field_path = ".".join(str(loc) for loc in error["loc"])
166
+ full_field_path = f"situations.{sit_name}.{error_field_path}"
167
+ message = error["msg"]
168
+ line_number = overlay.line_info.get_line(full_field_path)
169
+
170
+ validation_info.add_error(
171
+ field_path=full_field_path,
172
+ message=message,
173
+ line_number=line_number,
174
+ )
175
+
176
+ # Merge directories
177
+ merged_directories: dict[str, DirectoryDefinition] = {}
178
+
179
+ for dir_name, base_dir in base.directories.items():
180
+ if dir_name in overlay.directories:
181
+ # Field-level merge
182
+ merged_dir = DirectoryDefinition.merge(
183
+ base=base_dir,
184
+ overlay_data=overlay.directories[dir_name],
185
+ field_path=f"directories.{dir_name}",
186
+ validation_info=validation_info,
187
+ line_info=overlay.line_info,
188
+ )
189
+ merged_directories[dir_name] = merged_dir
190
+
191
+ validation_info.add_override(
192
+ category=ProjectOverrideCategory.DIRECTORY,
193
+ name=dir_name,
194
+ action=ProjectOverrideAction.MODIFIED,
195
+ )
196
+ else:
197
+ # Inherit from base
198
+ merged_directories[dir_name] = base_dir
199
+
200
+ # Add new directories from overlay
201
+ for dir_name, dir_data in overlay.directories.items():
202
+ if dir_name not in base.directories:
203
+ # New directory - construct from scratch
204
+ # Add name to dict for model_validate
205
+ dir_data_with_name = {"name": dir_name, **dir_data}
206
+
207
+ try:
208
+ new_dir = DirectoryDefinition.model_validate(dir_data_with_name)
209
+ merged_directories[dir_name] = new_dir
210
+
211
+ validation_info.add_override(
212
+ category=ProjectOverrideCategory.DIRECTORY,
213
+ name=dir_name,
214
+ action=ProjectOverrideAction.ADDED,
215
+ )
216
+ except ValidationError as e:
217
+ # Convert Pydantic validation errors
218
+ for error in e.errors():
219
+ error_field_path = ".".join(str(loc) for loc in error["loc"])
220
+ full_field_path = f"directories.{dir_name}.{error_field_path}"
221
+ message = error["msg"]
222
+ line_number = overlay.line_info.get_line(full_field_path)
223
+
224
+ validation_info.add_error(
225
+ field_path=full_field_path,
226
+ message=message,
227
+ line_number=line_number,
228
+ )
229
+
230
+ # Merge environment
231
+ merged_environment = {**base.environment}
232
+ for key, value in overlay.environment.items():
233
+ action = ProjectOverrideAction.MODIFIED if key in base.environment else ProjectOverrideAction.ADDED
234
+ merged_environment[key] = value
235
+
236
+ validation_info.add_override(
237
+ category=ProjectOverrideCategory.ENVIRONMENT,
238
+ name=key,
239
+ action=action,
240
+ )
241
+
242
+ # Use overlay metadata, fall back to base for description
243
+ merged_description = overlay.description if overlay.description is not None else base.description
244
+
245
+ return ProjectTemplate(
246
+ project_template_schema_version=overlay.project_template_schema_version,
247
+ name=overlay.name,
248
+ situations=merged_situations,
249
+ directories=merged_directories,
250
+ environment=merged_environment,
251
+ description=merged_description,
252
+ )