griptape-nodes 0.59.3__py3-none-any.whl → 0.60.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 (42) hide show
  1. griptape_nodes/cli/commands/libraries.py +21 -1
  2. griptape_nodes/common/macro_parser/__init__.py +28 -0
  3. griptape_nodes/common/macro_parser/core.py +230 -0
  4. griptape_nodes/common/macro_parser/exceptions.py +23 -0
  5. griptape_nodes/common/macro_parser/formats.py +170 -0
  6. griptape_nodes/common/macro_parser/matching.py +134 -0
  7. griptape_nodes/common/macro_parser/parsing.py +172 -0
  8. griptape_nodes/common/macro_parser/resolution.py +168 -0
  9. griptape_nodes/common/macro_parser/segments.py +42 -0
  10. griptape_nodes/exe_types/core_types.py +241 -4
  11. griptape_nodes/exe_types/node_types.py +7 -1
  12. griptape_nodes/exe_types/param_components/huggingface/__init__.py +1 -0
  13. griptape_nodes/exe_types/param_components/huggingface/huggingface_model_parameter.py +168 -0
  14. griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_file_parameter.py +38 -0
  15. griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_parameter.py +33 -0
  16. griptape_nodes/exe_types/param_components/huggingface/huggingface_utils.py +136 -0
  17. griptape_nodes/exe_types/param_components/log_parameter.py +136 -0
  18. griptape_nodes/exe_types/param_components/seed_parameter.py +59 -0
  19. griptape_nodes/exe_types/param_types/__init__.py +1 -0
  20. griptape_nodes/exe_types/param_types/parameter_bool.py +221 -0
  21. griptape_nodes/exe_types/param_types/parameter_float.py +179 -0
  22. griptape_nodes/exe_types/param_types/parameter_int.py +183 -0
  23. griptape_nodes/exe_types/param_types/parameter_number.py +380 -0
  24. griptape_nodes/exe_types/param_types/parameter_string.py +232 -0
  25. griptape_nodes/node_library/library_registry.py +2 -1
  26. griptape_nodes/retained_mode/events/app_events.py +21 -0
  27. griptape_nodes/retained_mode/events/os_events.py +142 -6
  28. griptape_nodes/retained_mode/events/parameter_events.py +2 -0
  29. griptape_nodes/retained_mode/griptape_nodes.py +14 -0
  30. griptape_nodes/retained_mode/managers/agent_manager.py +5 -3
  31. griptape_nodes/retained_mode/managers/arbitrary_code_exec_manager.py +19 -1
  32. griptape_nodes/retained_mode/managers/library_manager.py +8 -1
  33. griptape_nodes/retained_mode/managers/node_manager.py +14 -1
  34. griptape_nodes/retained_mode/managers/os_manager.py +403 -124
  35. griptape_nodes/retained_mode/managers/user_manager.py +120 -0
  36. griptape_nodes/retained_mode/managers/workflow_manager.py +44 -34
  37. griptape_nodes/traits/multi_options.py +26 -2
  38. griptape_nodes/utils/huggingface_utils.py +136 -0
  39. {griptape_nodes-0.59.3.dist-info → griptape_nodes-0.60.1.dist-info}/METADATA +1 -1
  40. {griptape_nodes-0.59.3.dist-info → griptape_nodes-0.60.1.dist-info}/RECORD +42 -19
  41. {griptape_nodes-0.59.3.dist-info → griptape_nodes-0.60.1.dist-info}/WHEEL +1 -1
  42. {griptape_nodes-0.59.3.dist-info → griptape_nodes-0.60.1.dist-info}/entry_points.txt +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import shutil
5
+ import stat
5
6
  import tarfile
6
7
  import tempfile
7
8
  from pathlib import Path
@@ -80,7 +81,7 @@ async def _sync_libraries(*, load_libraries_from_config: bool = True) -> None:
80
81
  if library_dir.is_dir():
81
82
  dest_library_dir = dest_nodes / library_dir.name
82
83
  if dest_library_dir.exists():
83
- shutil.rmtree(dest_library_dir)
84
+ shutil.rmtree(dest_library_dir, onexc=_remove_readonly)
84
85
  shutil.copytree(library_dir, dest_library_dir)
85
86
  console.print(f"[green]Synced library: {library_dir.name}[/green]")
86
87
 
@@ -94,3 +95,22 @@ async def _sync_libraries(*, load_libraries_from_config: bool = True) -> None:
94
95
  console.print(f"[red]Error initializing libraries: {e}[/red]")
95
96
 
96
97
  console.print("[bold green]Libraries synced.[/bold green]")
98
+
99
+
100
+ def _remove_readonly(func, path, excinfo) -> None: # noqa: ANN001, ARG001
101
+ """Handles read-only files and long paths on Windows during shutil.rmtree.
102
+
103
+ https://stackoverflow.com/a/50924863
104
+ """
105
+ if not GriptapeNodes.OSManager().is_windows():
106
+ return
107
+
108
+ long_path = Path(GriptapeNodes.OSManager().normalize_path_for_platform(path))
109
+
110
+ try:
111
+ Path.chmod(long_path, stat.S_IWRITE)
112
+ func(long_path)
113
+ except Exception as e:
114
+ console.print(f"[red]Error removing read-only file: {path}[/red]")
115
+ console.print(f"[red]Details: {e}[/red]")
116
+ raise
@@ -0,0 +1,28 @@
1
+ """Macro language parser for template-based path generation."""
2
+
3
+ from griptape_nodes.common.macro_parser.core import ParsedMacro
4
+ from griptape_nodes.common.macro_parser.exceptions import MacroResolutionError, MacroSyntaxError
5
+ from griptape_nodes.common.macro_parser.formats import (
6
+ DateFormat,
7
+ LowerCaseFormat,
8
+ NumericPaddingFormat,
9
+ SeparatorFormat,
10
+ SlugFormat,
11
+ UpperCaseFormat,
12
+ )
13
+ from griptape_nodes.common.macro_parser.segments import ParsedStaticValue, ParsedVariable, VariableInfo
14
+
15
+ __all__ = [
16
+ "DateFormat",
17
+ "LowerCaseFormat",
18
+ "MacroResolutionError",
19
+ "MacroSyntaxError",
20
+ "NumericPaddingFormat",
21
+ "ParsedMacro",
22
+ "ParsedStaticValue",
23
+ "ParsedVariable",
24
+ "SeparatorFormat",
25
+ "SlugFormat",
26
+ "UpperCaseFormat",
27
+ "VariableInfo",
28
+ ]
@@ -0,0 +1,230 @@
1
+ """Core ParsedMacro class - main API for macro templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from griptape_nodes.common.macro_parser.exceptions import MacroResolutionError, MacroSyntaxError
8
+ from griptape_nodes.common.macro_parser.matching import extract_unknown_variables
9
+ from griptape_nodes.common.macro_parser.parsing import parse_segments
10
+ from griptape_nodes.common.macro_parser.resolution import partial_resolve
11
+ from griptape_nodes.common.macro_parser.segments import ParsedStaticValue, ParsedVariable, VariableInfo
12
+
13
+ if TYPE_CHECKING:
14
+ from griptape_nodes.retained_mode.managers.secrets_manager import SecretsManager
15
+
16
+
17
+ class ParsedMacro:
18
+ """Parsed macro template with methods for resolving and matching paths.
19
+
20
+ This is the main API class for working with macro templates.
21
+ """
22
+
23
+ def __init__(self, template: str) -> None:
24
+ """Parse a macro template string, validating syntax."""
25
+ self.template = template
26
+
27
+ try:
28
+ segments = parse_segments(template)
29
+ except MacroSyntaxError as err:
30
+ msg = f"Attempted to parse template string '{template}'. Failed due to: {err}"
31
+ raise MacroSyntaxError(msg) from err
32
+
33
+ if not segments:
34
+ segments.append(ParsedStaticValue(text=""))
35
+ self.segments = segments
36
+
37
+ def get_variables(self) -> list[VariableInfo]:
38
+ """Extract all VariableInfo from parsed segments."""
39
+ return [seg.info for seg in self.segments if isinstance(seg, ParsedVariable)]
40
+
41
+ def resolve(
42
+ self,
43
+ variables: dict[str, str | int],
44
+ secrets_manager: SecretsManager,
45
+ ) -> str:
46
+ """Fully resolve the macro template with variable values."""
47
+ # Partially resolve with known variables
48
+ partial = partial_resolve(self.template, self.segments, variables, secrets_manager)
49
+
50
+ # Check if fully resolved
51
+ if not partial.is_fully_resolved():
52
+ unresolved = partial.get_unresolved_variables()
53
+ unresolved_names = [var.info.name for var in unresolved]
54
+ msg = f"Cannot fully resolve macro - missing required variables: {', '.join(unresolved_names)}"
55
+ raise MacroResolutionError(msg)
56
+
57
+ # Convert to string
58
+ return partial.to_string()
59
+
60
+ def matches(
61
+ self,
62
+ path: str,
63
+ known_variables: dict[str, str | int],
64
+ secrets_manager: SecretsManager,
65
+ ) -> bool:
66
+ """Check if a path matches this template."""
67
+ result = self.find_matches_detailed(path, known_variables, secrets_manager)
68
+ return result is not None
69
+
70
+ def extract_variables(
71
+ self,
72
+ path: str,
73
+ known_variables: dict[str, str | int],
74
+ secrets_manager: SecretsManager,
75
+ ) -> dict[str, str | int] | None:
76
+ """Extract variable values from a path (plain string keys)."""
77
+ detailed = self.find_matches_detailed(path, known_variables, secrets_manager)
78
+ if detailed is None:
79
+ return None
80
+ # Convert VariableInfo keys to plain string keys
81
+ return {var_info.name: value for var_info, value in detailed.items()}
82
+
83
+ def find_matches_detailed(
84
+ self,
85
+ path: str,
86
+ known_variables: dict[str, str | int],
87
+ secrets_manager: SecretsManager,
88
+ ) -> dict[VariableInfo, str | int] | None:
89
+ """Extract variable values from a path with metadata (greedy match).
90
+
91
+ This is the advanced version that returns detailed variable metadata with VariableInfo keys.
92
+ Most callers should use extract_variables() for plain dict or matches() for boolean check.
93
+
94
+ Given a parsed template and a path, extracts variable values by matching
95
+ the path against the template pattern. Known variables are resolved before
96
+ matching to reduce ambiguity. Uses greedy matching strategy to return a single
97
+ result instead of exploring all possible interpretations.
98
+
99
+ MATCHING SCENARIOS (how this method handles different cases):
100
+
101
+ Scenario A: All variables known, path matches
102
+ Template: "{inputs}/{file_name}"
103
+ Known: {"inputs": "inputs", "file_name": "photo.jpg"}
104
+ Path: "inputs/photo.jpg"
105
+ Result: {"inputs": "inputs", "file_name": "photo.jpg"}
106
+ Flow: Step 1 → fully resolved → Step 2 → exact match → return result
107
+
108
+ Scenario B: All variables known, path doesn't match
109
+ Template: "{inputs}/{file_name}"
110
+ Known: {"inputs": "inputs", "file_name": "photo.jpg"}
111
+ Path: "outputs/photo.jpg"
112
+ Result: None
113
+ Flow: Step 1 → fully resolved → Step 2 → no match → return None
114
+
115
+ Scenario C: Some variables known, path matches
116
+ Template: "{inputs}/{workflow_name}/{file_name}"
117
+ Known: {"inputs": "inputs"}
118
+ Path: "inputs/my_workflow/photo.jpg"
119
+ Result: {"inputs": "inputs", "workflow_name": "my_workflow", "file_name": "photo.jpg"}
120
+ Flow: Step 1 → partial resolve → Step 2 skipped → Step 3 → static validated
121
+ → Step 4 → extract unknowns (workflow_name, file_name) → merge with knowns → return
122
+
123
+ Scenario D: Some variables known, known variable value doesn't match path
124
+ Template: "{inputs}/{workflow_name}/{file_name}"
125
+ Known: {"inputs": "outputs"}
126
+ Path: "inputs/my_workflow/photo.jpg"
127
+ Result: None
128
+ Flow: Step 1 → partial resolve → Step 2 skipped → Step 3 → static mismatch → return None
129
+
130
+ Scenario E: Optional variable present in path
131
+ Template: "{inputs}/{workflow_name?:_}{file_name}"
132
+ Known: {"inputs": "inputs"}
133
+ Path: "inputs/my_workflow_photo.jpg"
134
+ Result: {"inputs": "inputs", "workflow_name": "my_workflow", "file_name": "photo.jpg"}
135
+ Flow: Step 1 → partial resolve → Step 2 skipped → Step 3 → validated
136
+ → Step 4 → extract with separator matching → return
137
+
138
+ Scenario F: Optional variable omitted from path
139
+ Template: "{inputs}/{workflow_name?:_}{file_name}"
140
+ Known: {"inputs": "inputs"}
141
+ Path: "inputs/photo.jpg"
142
+ Result: {"inputs": "inputs", "file_name": "photo.jpg"}
143
+ Flow: Step 1 → partial resolve (optional removed) → Step 2 skipped → Step 3 → validated
144
+ → Step 4 → extract file_name only → return
145
+
146
+ Scenario G: Multiple unknowns with delimiters
147
+ Template: "{inputs}/{dir}/{file_name}.{ext}"
148
+ Known: {"inputs": "inputs"}
149
+ Path: "inputs/render/output.png"
150
+ Result: {"inputs": "inputs", "dir": "render", "file_name": "output", "ext": "png"}
151
+ Flow: Step 1 → partial resolve → Step 2 skipped → Step 3 → validated
152
+ → Step 4 → extract dir, file_name, ext using "/" and "." delimiters → return
153
+
154
+ Scenario H: Format spec reversal (numeric padding)
155
+ Template: "{inputs}/{frame:03}.png"
156
+ Known: {"inputs": "inputs"}
157
+ Path: "inputs/005.png"
158
+ Result: {"inputs": "inputs", "frame": 5} # Note: integer value
159
+ Flow: Step 1 → partial resolve → Step 2 skipped → Step 3 → validated
160
+ → Step 4 → extract "005", reverse format spec → 5 → return
161
+
162
+ Args:
163
+ path: Actual path string to match against template
164
+ known_variables: Dictionary of variables with known values. These will be
165
+ resolved before matching to reduce ambiguity. Pass empty
166
+ dict {} if no variables are known.
167
+ secrets_manager: SecretsManager instance for resolving env vars in known variables
168
+
169
+ Returns:
170
+ Dictionary mapping VariableInfo to extracted values, or None if path doesn't
171
+ match the template pattern. Uses greedy matching to return a single result.
172
+ """
173
+ # STEP 1: Partial resolve - resolve known variables into static text
174
+ # This reduces the matching problem from "match everything" to "match only the unknowns"
175
+ #
176
+ # Scenarios affected:
177
+ # - All scenarios: always runs first
178
+ # - Scenarios A, B: will be fully resolved (all variables known)
179
+ # - Scenarios C-H: will have mix of static and unknown variables
180
+ # - Scenarios E, F: optional variables not in known_variables are removed
181
+ partial = partial_resolve(self.template, self.segments, known_variables, secrets_manager)
182
+
183
+ # STEP 2: Check if fully resolved (all variables were known)
184
+ # If so, we can do a direct string comparison
185
+ #
186
+ # Scenarios affected:
187
+ # - Scenario A: fully resolved, path matches → return result dict
188
+ # - Scenario B: fully resolved, path doesn't match → return None
189
+ # - Scenarios C-H: NOT fully resolved, skip this step
190
+ if partial.is_fully_resolved():
191
+ resolved_path = partial.to_string()
192
+ if resolved_path == path:
193
+ # Scenario A: exact match
194
+ result: dict[VariableInfo, str | int] = {}
195
+ for segment in self.segments:
196
+ if isinstance(segment, ParsedVariable) and segment.info.name in known_variables:
197
+ result[segment.info] = known_variables[segment.info.name]
198
+ return result
199
+ # Scenario B: no match
200
+ return None
201
+
202
+ # STEP 3: Extract unknown variables from path
203
+ # Use static segments as anchors to extract variable values between them
204
+ #
205
+ # Scenarios affected:
206
+ # - Scenario C: extract workflow_name="my_workflow", file_name="photo.jpg"
207
+ # - Scenario D: static "outputs" doesn't match "inputs/" → return None
208
+ # - Scenario E: extract workflow_name="my_workflow", file_name="photo.jpg" (separator matched)
209
+ # - Scenario F: extract file_name="photo.jpg" (optional was removed in Step 1)
210
+ # - Scenario G: extract dir="render", file_name="output", ext="png" (multiple delimiters)
211
+ # - Scenario H: extract frame="005", reverse format spec → 5
212
+ extracted = extract_unknown_variables(partial.segments, path)
213
+ if extracted is None:
214
+ # Extraction failed (static segments don't match or can't extract variables)
215
+ return None
216
+
217
+ # STEP 4: Merge extracted unknowns with known variables to create complete result
218
+ # The extracted dict contains only extracted unknowns, need to add knowns back in
219
+ #
220
+ # Scenarios affected (D was eliminated in Step 3):
221
+ # - Scenario C: merge inputs="inputs" → final: {inputs, workflow_name, file_name}
222
+ # - Scenario E: merge inputs="inputs" → final: {inputs, workflow_name, file_name}
223
+ # - Scenario F: merge inputs="inputs" → final: {inputs, file_name}
224
+ # - Scenario G: merge inputs="inputs" → final: {inputs, dir, file_name, ext}
225
+ # - Scenario H: merge inputs="inputs" → final: {inputs, frame}
226
+ for segment in self.segments:
227
+ if isinstance(segment, ParsedVariable) and segment.info.name in known_variables:
228
+ extracted[segment.info] = known_variables[segment.info.name]
229
+
230
+ return extracted
@@ -0,0 +1,23 @@
1
+ """Exceptions for macro language parsing and resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class MacroSyntaxError(Exception):
7
+ """Raised when macro template has invalid syntax.
8
+
9
+ Examples of syntax errors:
10
+ - Unbalanced braces: "{inputs}/{file_name"
11
+ - Invalid format specifier: "{index:xyz}"
12
+ - Nested braces: "{outer_{inner}}"
13
+ """
14
+
15
+
16
+ class MacroResolutionError(Exception):
17
+ """Raised when macro cannot be resolved with provided variables.
18
+
19
+ Examples of resolution errors:
20
+ - Required variable missing from variables dict
21
+ - Environment variable referenced but not found in environment
22
+ - Format specifier cannot be applied to value type (e.g., :03 on string)
23
+ """
@@ -0,0 +1,170 @@
1
+ """Format specifier classes for macro variable transformations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass
8
+
9
+ from griptape_nodes.common.macro_parser.exceptions import MacroResolutionError
10
+
11
+
12
+ @dataclass
13
+ class FormatSpec(ABC):
14
+ """Base class for format specifiers."""
15
+
16
+ @abstractmethod
17
+ def apply(self, value: str | int) -> str | int:
18
+ """Apply this format spec to a value during resolution.
19
+
20
+ Args:
21
+ value: Value to transform
22
+
23
+ Returns:
24
+ Transformed value
25
+
26
+ Raises:
27
+ MacroResolutionError: If format cannot be applied to value type
28
+
29
+ Examples:
30
+ >>> # NumericPaddingFormat(width=3).apply(5)
31
+ "005"
32
+ >>> # LowerCaseFormat().apply("MyWorkflow")
33
+ "myworkflow"
34
+ """
35
+
36
+ @abstractmethod
37
+ def reverse(self, value: str) -> str | int:
38
+ """Reverse this format spec during matching (best effort).
39
+
40
+ Args:
41
+ value: Formatted string value from a path
42
+
43
+ Returns:
44
+ Original value before format was applied
45
+
46
+ Raises:
47
+ MacroResolutionError: If value cannot be reversed
48
+
49
+ Examples:
50
+ >>> # NumericPaddingFormat(width=3).reverse("005")
51
+ 5
52
+ >>> # SeparatorFormat(separator="_").reverse("workflow_")
53
+ "workflow"
54
+ """
55
+
56
+
57
+ @dataclass
58
+ class SeparatorFormat(FormatSpec):
59
+ """Separator appended to variable value like :_, :/, :foo.
60
+
61
+ Must be first format spec in list (if present).
62
+ Syntax: {var:_} or {var:'lower'} (quotes to disambiguate from transformations)
63
+ """
64
+
65
+ separator: str # e.g., "_", "/", "foo"
66
+
67
+ def apply(self, value: str | int) -> str:
68
+ """Append separator to value."""
69
+ return str(value) + self.separator
70
+
71
+ def reverse(self, value: str) -> str:
72
+ """Remove separator from end of value."""
73
+ if value.endswith(self.separator):
74
+ return value[: -len(self.separator)]
75
+ return value
76
+
77
+
78
+ @dataclass
79
+ class NumericPaddingFormat(FormatSpec):
80
+ """Numeric padding format like :03, :04."""
81
+
82
+ width: int # e.g., 3 for :03
83
+
84
+ def apply(self, value: str | int) -> str:
85
+ """Apply numeric padding: 5 → "005"."""
86
+ if not isinstance(value, int):
87
+ if not str(value).isdigit():
88
+ msg = (
89
+ f"Numeric padding format :{self.width:0{self.width}d} "
90
+ f"cannot be applied to non-numeric value: {value}"
91
+ )
92
+ raise MacroResolutionError(msg)
93
+ value = int(value)
94
+ return f"{value:0{self.width}d}"
95
+
96
+ def reverse(self, value: str) -> int:
97
+ """Reverse numeric padding: "005" → 5."""
98
+ try:
99
+ return int(value)
100
+ except ValueError as e:
101
+ msg = f"Cannot parse '{value}' as integer"
102
+ raise MacroResolutionError(msg) from e
103
+
104
+
105
+ @dataclass
106
+ class LowerCaseFormat(FormatSpec):
107
+ """Lowercase transformation :lower."""
108
+
109
+ def apply(self, value: str | int) -> str:
110
+ """Convert value to lowercase."""
111
+ return str(value).lower()
112
+
113
+ def reverse(self, value: str) -> str:
114
+ """Cannot reliably reverse case - return as-is."""
115
+ return value
116
+
117
+
118
+ @dataclass
119
+ class UpperCaseFormat(FormatSpec):
120
+ """Uppercase transformation :upper."""
121
+
122
+ def apply(self, value: str | int) -> str:
123
+ """Convert value to uppercase."""
124
+ return str(value).upper()
125
+
126
+ def reverse(self, value: str) -> str:
127
+ """Cannot reliably reverse case - return as-is."""
128
+ return value
129
+
130
+
131
+ @dataclass
132
+ class SlugFormat(FormatSpec):
133
+ """Slugification format :slug (spaces to hyphens, safe chars only)."""
134
+
135
+ def apply(self, value: str | int) -> str:
136
+ """Convert to slug: spaces→hyphens, lowercase, safe chars."""
137
+ s = str(value).lower()
138
+ s = re.sub(r"\s+", "-", s) # Spaces to hyphens
139
+ s = re.sub(r"[^a-z0-9\-_]", "", s) # Keep only safe chars
140
+ return s
141
+
142
+ def reverse(self, value: str) -> str:
143
+ """Cannot reliably reverse slugification - return as-is."""
144
+ return value
145
+
146
+
147
+ @dataclass
148
+ class DateFormat(FormatSpec):
149
+ """Date formatting like :%Y-%m-%d."""
150
+
151
+ pattern: str # e.g., "%Y-%m-%d"
152
+
153
+ def apply(self, _value: str | int) -> str:
154
+ """Apply date formatting."""
155
+ # TODO(https://github.com/griptape-ai/griptape-nodes/issues/2717): Implement date formatting
156
+ msg = "DateFormat not yet fully implemented"
157
+ raise MacroResolutionError(msg)
158
+
159
+ def reverse(self, value: str) -> str:
160
+ """Attempt to parse date string."""
161
+ # TODO(https://github.com/griptape-ai/griptape-nodes/issues/2717): Implement date parsing
162
+ return value
163
+
164
+
165
+ # Module-level registry of known format transformations
166
+ FORMAT_REGISTRY: dict[str, FormatSpec] = {
167
+ "lower": LowerCaseFormat(),
168
+ "upper": UpperCaseFormat(),
169
+ "slug": SlugFormat(),
170
+ }
@@ -0,0 +1,134 @@
1
+ """Matching logic for extracting variables from paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from griptape_nodes.common.macro_parser.exceptions import MacroSyntaxError
8
+ from griptape_nodes.common.macro_parser.segments import (
9
+ ParsedSegment,
10
+ ParsedStaticValue,
11
+ ParsedVariable,
12
+ VariableInfo,
13
+ )
14
+
15
+ if TYPE_CHECKING:
16
+ from griptape_nodes.common.macro_parser.formats import FormatSpec
17
+
18
+
19
+ def extract_unknown_variables(
20
+ pattern_segments: list[ParsedSegment],
21
+ path: str,
22
+ ) -> dict[VariableInfo, str | int] | None:
23
+ """Extract unknown variable values from path (greedy matching).
24
+
25
+ Args:
26
+ pattern_segments: Partially resolved segments to match against
27
+ path: Path string to extract variables from
28
+
29
+ Returns:
30
+ Dict mapping VariableInfo to extracted values, or None if no match.
31
+ """
32
+ current_match: dict[VariableInfo, str | int] = {}
33
+ current_pos = 0
34
+
35
+ for i, segment in enumerate(pattern_segments):
36
+ match segment:
37
+ case ParsedStaticValue():
38
+ # Verify static text matches at current position
39
+ if not path[current_pos:].startswith(segment.text):
40
+ # Static text doesn't match at this position
41
+ return None
42
+ current_pos += len(segment.text)
43
+ case ParsedVariable():
44
+ result = extract_single_variable(segment, pattern_segments[i + 1 :], path, current_pos)
45
+ if result is None:
46
+ return None
47
+ value, new_pos = result
48
+ current_match[segment.info] = value
49
+ current_pos = new_pos
50
+ case _:
51
+ msg = f"Unexpected segment type: {type(segment).__name__}"
52
+ raise MacroSyntaxError(msg)
53
+
54
+ return current_match
55
+
56
+
57
+ def extract_single_variable(
58
+ variable: ParsedVariable,
59
+ remaining_segments: list[ParsedSegment],
60
+ path: str,
61
+ start_pos: int,
62
+ ) -> tuple[str | int, int] | None:
63
+ """Extract value for a single variable from path.
64
+
65
+ Args:
66
+ variable: The variable to extract
67
+ remaining_segments: Segments after this variable
68
+ path: Full path being matched
69
+ start_pos: Position in path to start extraction
70
+
71
+ Returns:
72
+ Tuple of (extracted_value, new_position) or None if extraction fails.
73
+ """
74
+ # Find next static segment to determine end position
75
+ next_static = find_next_static(remaining_segments)
76
+ if next_static:
77
+ end_pos = path.find(next_static.text, start_pos)
78
+ if end_pos == -1:
79
+ # Can't find next static - no match
80
+ return None
81
+ else:
82
+ # No more static segments - consume to end
83
+ end_pos = len(path)
84
+
85
+ # Extract raw value
86
+ raw_value = path[start_pos:end_pos]
87
+
88
+ # Reverse format specs
89
+ reversed_value = reverse_format_specs(raw_value, variable.format_specs)
90
+ if reversed_value is None:
91
+ # Can't reverse format specs - no match
92
+ return None
93
+
94
+ return (reversed_value, end_pos)
95
+
96
+
97
+ def find_next_static(segments: list[ParsedSegment]) -> ParsedStaticValue | None:
98
+ """Find next static segment in list.
99
+
100
+ Args:
101
+ segments: List of segments to search
102
+
103
+ Returns:
104
+ First ParsedStaticValue found, or None if no static segments.
105
+ """
106
+ for seg in segments:
107
+ if isinstance(seg, ParsedStaticValue):
108
+ return seg
109
+ # No static segment found
110
+ return None
111
+
112
+
113
+ def reverse_format_specs(value: str, format_specs: list[FormatSpec]) -> str | int | None:
114
+ """Apply format spec reversal in reverse order.
115
+
116
+ Args:
117
+ value: String value extracted from path
118
+ format_specs: List of format specs to reverse
119
+
120
+ Returns:
121
+ Reversed value (might be int after NumericPaddingFormat.reverse), or None if reversal fails.
122
+ """
123
+ result: str | int = value
124
+ # Apply in reverse order (last spec first)
125
+ for spec in reversed(format_specs):
126
+ # reverse() expects str but result might be int, so convert if needed
127
+ str_result = str(result) if isinstance(result, int) else result
128
+ reversed_result = spec.reverse(str_result)
129
+ if reversed_result is None:
130
+ # Can't reverse this format spec
131
+ return None
132
+ result = reversed_result
133
+ # Return reversed value (might be int after NumericPaddingFormat.reverse)
134
+ return result