From 7bf1c721e444da5ba2c3e5ad432371c577b00287 Mon Sep 17 00:00:00 2001
From: Celal Zamanoglu <95054566+celalzamanoglu@users.noreply.github.com>
Date: Fri, 6 Feb 2026 00:58:36 +0300
Subject: [PATCH] Fix Jinja2 template errors from invalid parameter/block names
with special characters (SKY-7356) (#4644)
---
.../panels/WorkflowParameterEditPanel.tsx | 49 ++-
.../workflows/hooks/useLabelChangeHandler.ts | 55 ++-
.../script_generations/generate_script.py | 30 +-
skyvern/forge/sdk/routes/agent_protocol.py | 13 +-
skyvern/forge/sdk/workflow/exceptions.py | 9 +-
skyvern/schemas/workflows.py | 348 +++++++++++++++++-
skyvern/utils/strings.py | 28 ++
skyvern/utils/templating.py | 22 ++
8 files changed, 514 insertions(+), 40 deletions(-)
diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterEditPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterEditPanel.tsx
index 77114bb9..e0e8164a 100644
--- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterEditPanel.tsx
+++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterEditPanel.tsx
@@ -104,6 +104,42 @@ function header(
return `${prefix} Context Parameter`;
}
+/**
+ * Validates that a parameter key is a valid Python/Jinja2 identifier.
+ * Parameter keys are used in Jinja2 templates, so they must be valid identifiers.
+ * Returns an error message if invalid, or null if valid.
+ */
+function validateParameterKey(key: string): string | null {
+ if (!key) return null; // Empty key is handled separately
+
+ // Check for whitespace
+ if (/\s/.test(key)) {
+ return "Key cannot contain whitespace characters. Consider using underscores (_) instead.";
+ }
+
+ // Check if it's a valid Python identifier:
+ // - Must start with a letter (a-z, A-Z) or underscore (_)
+ // - Can only contain letters, digits (0-9), and underscores
+ const validIdentifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+ if (!validIdentifierRegex.test(key)) {
+ if (/^[0-9]/.test(key)) {
+ return "Key cannot start with a digit. Parameter keys must start with a letter or underscore.";
+ }
+ if (key.includes("/")) {
+ return "Key cannot contain '/' characters. Use underscores instead (e.g., 'State_or_Province' instead of 'State/Province').";
+ }
+ if (key.includes("-")) {
+ return "Key cannot contain '-' characters. Use underscores instead (e.g., 'my_parameter' instead of 'my-parameter').";
+ }
+ if (key.includes(".")) {
+ return "Key cannot contain '.' characters. Use underscores instead.";
+ }
+ return "Key must be a valid identifier (only letters, digits, and underscores; cannot start with a digit).";
+ }
+
+ return null;
+}
+
// Helper to detect initial credential data type from existing parameter
function detectInitialCredentialDataType(
initialValues: ParametersState[number] | undefined,
@@ -149,7 +185,7 @@ function WorkflowParameterEditPanel({
const isCloud = useContext(CloudContext);
const isEditMode = !!initialValues;
const [key, setKey] = useState(initialValues?.key ?? "");
- const hasWhitespace = /\s/.test(key);
+ const keyValidationError = validateParameterKey(key);
// Detect initial values for backward compatibility
const isBitwardenCredential =
@@ -314,10 +350,8 @@ function WorkflowParameterEditPanel({
setKey(e.target.value)} />
- {hasWhitespace && (
-
- Spaces are not allowed, consider using _
-
+ {keyValidationError && (
+
{keyValidationError}
)}
@@ -769,12 +803,11 @@ function WorkflowParameterEditPanel({
});
return;
}
- if (hasWhitespace) {
+ if (keyValidationError) {
toast({
variant: "destructive",
title: "Failed to save parameter",
- description:
- "Key cannot contain whitespace characters. Consider using underscores (_) instead.",
+ description: keyValidationError,
});
return;
}
diff --git a/skyvern-frontend/src/routes/workflows/hooks/useLabelChangeHandler.ts b/skyvern-frontend/src/routes/workflows/hooks/useLabelChangeHandler.ts
index 4c3c3aac..17d7feac 100644
--- a/skyvern-frontend/src/routes/workflows/hooks/useLabelChangeHandler.ts
+++ b/skyvern-frontend/src/routes/workflows/hooks/useLabelChangeHandler.ts
@@ -7,6 +7,40 @@ import {
} from "../editor/workflowEditorUtils";
import { useState } from "react";
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
+import { toast } from "@/components/ui/use-toast";
+
+/**
+ * Sanitizes a block label to be a valid Python/Jinja2 identifier.
+ * Block labels are used to create output parameter keys (e.g., '{label}_output')
+ * which are then used as Jinja2 template variable names.
+ */
+function sanitizeBlockLabel(value: string): {
+ sanitized: string;
+ wasModified: boolean;
+} {
+ const original = value;
+
+ // Replace any character that's not a letter, digit, or underscore with underscore
+ let sanitized = value.replace(/[^a-zA-Z0-9_]/g, "_");
+
+ // Collapse multiple consecutive underscores into one
+ sanitized = sanitized.replace(/_+/g, "_");
+
+ // Remove leading/trailing underscores for cleaner labels
+ sanitized = sanitized.replace(/^_+|_+$/g, "");
+
+ // If starts with a digit (after cleanup), prepend an underscore
+ if (/^[0-9]/.test(sanitized)) {
+ sanitized = "_" + sanitized;
+ }
+
+ // If everything was stripped, provide a default
+ if (!sanitized) {
+ sanitized = "block";
+ }
+
+ return { sanitized, wasModified: original !== sanitized };
+}
type Props = {
id: string;
@@ -24,13 +58,22 @@ function useNodeLabelChangeHandler({ id, initialValue }: Props) {
function handleLabelChange(value: string) {
const existingLabels = nodes
- .filter(isWorkflowBlockNode)
+ .filter((n) => isWorkflowBlockNode(n) && n.id !== id)
.map((n) => n.data.label);
- const labelWithoutWhitespace = value.replace(/\s+/g, "_");
- const newLabel = getUniqueLabelForExistingNode(
- labelWithoutWhitespace,
- existingLabels,
- );
+
+ // Sanitize the label to be a valid Python identifier
+ const { sanitized, wasModified } = sanitizeBlockLabel(value);
+
+ // Show a toast if characters were modified
+ if (wasModified) {
+ toast({
+ title: "Block label adjusted",
+ description:
+ "Block labels can only contain letters, numbers, and underscores. Invalid characters have been replaced.",
+ });
+ }
+
+ const newLabel = getUniqueLabelForExistingNode(sanitized, existingLabels);
setLabel(newLabel);
setNodes(
getUpdatedNodesAfterLabelUpdateForParameterKeys(
diff --git a/skyvern/core/script_generations/generate_script.py b/skyvern/core/script_generations/generate_script.py
index dfdae3b9..0b8579c0 100644
--- a/skyvern/core/script_generations/generate_script.py
+++ b/skyvern/core/script_generations/generate_script.py
@@ -26,6 +26,7 @@ from skyvern.core.script_generations.generate_workflow_parameters import (
)
from skyvern.forge import app
from skyvern.schemas.workflows import FileStorageType
+from skyvern.utils.strings import sanitize_identifier
from skyvern.webeye.actions.action_types import ActionType
LOG = structlog.get_logger(__name__)
@@ -126,36 +127,23 @@ def sanitize_variable_name(name: str) -> str:
Sanitize a string to be a valid Python variable name.
- Converts to snake_case
- - Removes invalid characters
+ - Removes invalid characters (via shared sanitize_identifier)
- Ensures it doesn't start with a number
- Handles Python keywords by appending underscore
- - Removes empty spaces
+ - Converts to lowercase
"""
- # Remove leading/trailing whitespace and replace internal spaces with underscores
- name = name.strip().replace(" ", "_")
-
# Convert to snake_case: handle camelCase and PascalCase
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
- # Remove any characters that aren't alphanumeric or underscore
- name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
-
- # Convert to lowercase
+ # Convert to lowercase before sanitizing
name = name.lower()
- # Remove consecutive underscores
- name = re.sub(r"_+", "_", name)
+ # Use shared sanitize_identifier for core cleanup (uses "_" prefix for digit-leading names)
+ name = sanitize_identifier(name, default="param")
- # Remove leading/trailing underscores
- name = name.strip("_")
-
- # Ensure it doesn't start with a number
- if name and name[0].isdigit():
- name = f"param_{name}"
-
- # Handle empty string or invalid names
- if not name or name == "_":
- name = "param"
+ # For script variable names, use "param_" prefix instead of bare "_" for digit-leading names
+ if name.startswith("_") and len(name) > 1 and name[1].isdigit():
+ name = f"param{name}"
# Handle Python keywords
if keyword.iskeyword(name):
diff --git a/skyvern/forge/sdk/routes/agent_protocol.py b/skyvern/forge/sdk/routes/agent_protocol.py
index 9c9068b3..62aeb545 100644
--- a/skyvern/forge/sdk/routes/agent_protocol.py
+++ b/skyvern/forge/sdk/routes/agent_protocol.py
@@ -118,7 +118,13 @@ from skyvern.schemas.runs import (
WorkflowRunResponse,
)
from skyvern.schemas.webhooks import RetryRunWebhookRequest
-from skyvern.schemas.workflows import BlockType, WorkflowCreateYAMLRequest, WorkflowRequest, WorkflowStatus
+from skyvern.schemas.workflows import (
+ BlockType,
+ WorkflowCreateYAMLRequest,
+ WorkflowRequest,
+ WorkflowStatus,
+ sanitize_workflow_yaml_with_references,
+)
from skyvern.services import block_service, run_service, task_v1_service, task_v2_service, workflow_service
from skyvern.services.pdf_import_service import pdf_import_service
from skyvern.webeye.actions.actions import Action
@@ -536,6 +542,9 @@ async def create_workflow_legacy(
except yaml.YAMLError:
raise HTTPException(status_code=422, detail="Invalid YAML")
+ # Auto-sanitize block labels and update references for imports
+ workflow_yaml = sanitize_workflow_yaml_with_references(workflow_yaml)
+
try:
workflow_create_request = WorkflowCreateYAMLRequest.model_validate(workflow_yaml)
# Override folder_id if provided as query parameter
@@ -588,6 +597,8 @@ async def create_workflow(
try:
if data.yaml_definition:
workflow_json_from_yaml = yaml.safe_load(data.yaml_definition)
+ # Auto-sanitize block labels and update references for imports
+ workflow_json_from_yaml = sanitize_workflow_yaml_with_references(workflow_json_from_yaml)
workflow_definition = WorkflowCreateYAMLRequest.model_validate(workflow_json_from_yaml)
elif data.json_definition:
workflow_definition = data.json_definition
diff --git a/skyvern/forge/sdk/workflow/exceptions.py b/skyvern/forge/sdk/workflow/exceptions.py
index 42bed8d4..8fbd18e0 100644
--- a/skyvern/forge/sdk/workflow/exceptions.py
+++ b/skyvern/forge/sdk/workflow/exceptions.py
@@ -166,7 +166,10 @@ class InvalidWaitBlockTime(SkyvernException):
class FailedToFormatJinjaStyleParameter(SkyvernException):
def __init__(self, template: str, msg: str) -> None:
super().__init__(
- f"Failed to format Jinja style parameter {template}. Please make sure the variable reference is correct. reason: {msg}"
+ f"Failed to format Jinja style parameter '{template}'. "
+ f"Reason: {msg}. "
+ "If your block labels or parameter keys contain characters like '/', '-', or '.', "
+ "please rename them to use only letters, numbers, and underscores (e.g., 'State_Province' instead of 'State/Province')."
)
@@ -175,7 +178,9 @@ class MissingJinjaVariables(SkyvernException):
self.variables = variables
super().__init__(
- f"There are missing variables for '{template}'. Please make sure the variables are supplied. Missing variables: {variables}"
+ f"Missing variables for '{template}'. Missing: {variables}. "
+ "If your block labels or parameter keys contain characters like '/', '-', or '.', "
+ "please rename them to use only letters, numbers, and underscores (e.g., 'State_Province' instead of 'State/Province')."
)
diff --git a/skyvern/schemas/workflows.py b/skyvern/schemas/workflows.py
index 4b568a17..ec2fd986 100644
--- a/skyvern/schemas/workflows.py
+++ b/skyvern/schemas/workflows.py
@@ -3,11 +3,325 @@ from dataclasses import dataclass
from enum import StrEnum
from typing import Annotated, Any, Literal
+import structlog
from pydantic import BaseModel, Field, field_validator, model_validator
from skyvern.config import settings
from skyvern.forge.sdk.workflow.models.parameter import OutputParameter, ParameterType, WorkflowParameterType
from skyvern.schemas.runs import GeoTarget, ProxyLocation, RunEngine
+from skyvern.utils.strings import sanitize_identifier
+from skyvern.utils.templating import replace_jinja_reference
+
+LOG = structlog.get_logger()
+
+
+def sanitize_block_label(value: str) -> str:
+ """Sanitizes a block label to be a valid Python/Jinja2 identifier.
+
+ Block labels are used to create output parameter keys (e.g., '{label}_output')
+ which are then used as Jinja2 template variable names.
+
+ Args:
+ value: The raw label value to sanitize
+
+ Returns:
+ A sanitized label that is a valid Python identifier
+ """
+ return sanitize_identifier(value, default="block")
+
+
+def sanitize_parameter_key(value: str) -> str:
+ """Sanitizes a parameter key to be a valid Python/Jinja2 identifier.
+
+ Parameter keys are used as Jinja2 template variable names.
+
+ Args:
+ value: The raw key value to sanitize
+
+ Returns:
+ A sanitized key that is a valid Python identifier
+ """
+ return sanitize_identifier(value, default="parameter")
+
+
+def _replace_references_in_value(value: Any, old_key: str, new_key: str) -> Any:
+ """Recursively replaces Jinja references in a value (string, dict, or list)."""
+ if isinstance(value, str):
+ return replace_jinja_reference(value, old_key, new_key)
+ elif isinstance(value, dict):
+ return {k: _replace_references_in_value(v, old_key, new_key) for k, v in value.items()}
+ elif isinstance(value, list):
+ return [_replace_references_in_value(item, old_key, new_key) for item in value]
+ return value
+
+
+def _replace_direct_string_in_value(value: Any, old_key: str, new_key: str) -> Any:
+ """Recursively replaces exact string matches in a value (for fields like source_parameter_key)."""
+ if isinstance(value, str):
+ return new_key if value == old_key else value
+ elif isinstance(value, dict):
+ return {k: _replace_direct_string_in_value(v, old_key, new_key) for k, v in value.items()}
+ elif isinstance(value, list):
+ return [_replace_direct_string_in_value(item, old_key, new_key) for item in value]
+ return value
+
+
+def _make_unique(candidate: str, seen: set[str]) -> str:
+ """Appends a numeric suffix to make candidate unique within the seen set.
+
+ Args:
+ candidate: The candidate identifier
+ seen: Set of already-used identifiers (mutated -- the chosen name is added)
+
+ Returns:
+ A unique identifier, either candidate itself or candidate_N
+ """
+ if candidate not in seen:
+ seen.add(candidate)
+ return candidate
+ counter = 2
+ while f"{candidate}_{counter}" in seen:
+ counter += 1
+ unique = f"{candidate}_{counter}"
+ seen.add(unique)
+ return unique
+
+
+def _sanitize_blocks_recursive(
+ blocks: list[dict[str, Any]],
+ label_mapping: dict[str, str],
+ seen_labels: set[str],
+) -> list[dict[str, Any]]:
+ """Recursively sanitizes block labels and collects the label mapping.
+
+ Args:
+ blocks: List of block dictionaries
+ label_mapping: Dictionary to store old_label -> new_label mappings (mutated)
+ seen_labels: Set of already-used labels for collision avoidance (mutated)
+
+ Returns:
+ List of blocks with sanitized labels
+ """
+ sanitized_blocks = []
+ for block in blocks:
+ block = dict(block) # Make a copy to avoid mutating the original
+
+ # Sanitize the block's label
+ if "label" in block and isinstance(block["label"], str):
+ old_label = block["label"]
+ new_label = sanitize_block_label(old_label)
+ new_label = _make_unique(new_label, seen_labels)
+ if old_label != new_label:
+ label_mapping[old_label] = new_label
+ block["label"] = new_label
+ else:
+ # Even if unchanged, track it as seen for collision avoidance
+ seen_labels.add(old_label)
+
+ # Handle nested blocks in for_loop
+ if "loop_blocks" in block and isinstance(block["loop_blocks"], list):
+ block["loop_blocks"] = _sanitize_blocks_recursive(block["loop_blocks"], label_mapping, seen_labels)
+
+ sanitized_blocks.append(block)
+
+ return sanitized_blocks
+
+
+def _sanitize_parameters(
+ parameters: list[dict[str, Any]],
+ key_mapping: dict[str, str],
+ seen_keys: set[str],
+) -> list[dict[str, Any]]:
+ """Sanitizes parameter keys and collects the key mapping.
+
+ Args:
+ parameters: List of parameter dictionaries
+ key_mapping: Dictionary to store old_key -> new_key mappings (mutated)
+ seen_keys: Set of already-used keys for collision avoidance (mutated)
+
+ Returns:
+ List of parameters with sanitized keys
+ """
+ sanitized_params = []
+ for param in parameters:
+ param = dict(param) # Make a copy
+
+ if "key" in param and isinstance(param["key"], str):
+ old_key = param["key"]
+ new_key = sanitize_parameter_key(old_key)
+ new_key = _make_unique(new_key, seen_keys)
+ if old_key != new_key:
+ key_mapping[old_key] = new_key
+ param["key"] = new_key
+ else:
+ # Even if unchanged, track it as seen for collision avoidance
+ seen_keys.add(old_key)
+
+ sanitized_params.append(param)
+
+ return sanitized_params
+
+
+def _update_parameter_keys_in_blocks(
+ blocks: list[dict[str, Any]],
+ key_mapping: dict[str, str],
+) -> list[dict[str, Any]]:
+ """Updates parameter_keys arrays in blocks to use new parameter key names.
+
+ Args:
+ blocks: List of block dictionaries
+ key_mapping: Dictionary of old_key -> new_key mappings
+
+ Returns:
+ List of blocks with updated parameter_keys
+ """
+ updated_blocks = []
+ for block in blocks:
+ block = dict(block)
+
+ # Update parameter_keys array if present
+ if "parameter_keys" in block and isinstance(block["parameter_keys"], list):
+ block["parameter_keys"] = [
+ key_mapping.get(key, key) if isinstance(key, str) else key for key in block["parameter_keys"]
+ ]
+
+ # Handle nested blocks in for_loop
+ if "loop_blocks" in block and isinstance(block["loop_blocks"], list):
+ block["loop_blocks"] = _update_parameter_keys_in_blocks(block["loop_blocks"], key_mapping)
+
+ updated_blocks.append(block)
+
+ return updated_blocks
+
+
+def sanitize_workflow_yaml_with_references(workflow_yaml: dict[str, Any]) -> dict[str, Any]:
+ """Sanitizes block labels and parameter keys, and updates all references throughout the workflow.
+
+ This function:
+ 1. Sanitizes all block labels to be valid Python identifiers
+ 2. Sanitizes all parameter keys to be valid Python identifiers
+ 3. Updates all Jinja references from {old_key} to {new_key}
+ 4. Updates next_block_label if it references an old label
+ 5. Updates finally_block_label if it references an old label
+ 6. Updates parameter_keys arrays in blocks
+ 7. Updates source_parameter_key and other direct references
+
+ Args:
+ workflow_yaml: The parsed workflow YAML dictionary
+
+ Returns:
+ The workflow YAML with sanitized identifiers and updated references
+ """
+ workflow_yaml = dict(workflow_yaml) # Make a copy
+
+ workflow_definition = workflow_yaml.get("workflow_definition")
+ if not workflow_definition or not isinstance(workflow_definition, dict):
+ return workflow_yaml
+
+ workflow_definition = dict(workflow_definition) # Make a copy
+ workflow_yaml["workflow_definition"] = workflow_definition
+
+ # Step 1: Sanitize all block labels and collect the mapping
+ label_mapping: dict[str, str] = {} # old_label -> new_label
+ seen_labels: set[str] = set()
+ blocks = workflow_definition.get("blocks")
+ if blocks and isinstance(blocks, list):
+ workflow_definition["blocks"] = _sanitize_blocks_recursive(blocks, label_mapping, seen_labels)
+
+ # Step 2: Sanitize all parameter keys and collect the mapping
+ param_key_mapping: dict[str, str] = {} # old_key -> new_key
+ seen_keys: set[str] = set()
+ parameters = workflow_definition.get("parameters")
+ if parameters and isinstance(parameters, list):
+ workflow_definition["parameters"] = _sanitize_parameters(parameters, param_key_mapping, seen_keys)
+
+ # If nothing was changed, return early
+ if not label_mapping and not param_key_mapping:
+ return workflow_yaml
+
+ LOG.info(
+ "Auto-sanitized workflow identifiers during import",
+ sanitized_labels=label_mapping if label_mapping else None,
+ sanitized_parameter_keys=param_key_mapping if param_key_mapping else None,
+ )
+
+ # Step 3: Update all block label references
+ for old_label, new_label in label_mapping.items():
+ old_output_key = f"{old_label}_output"
+ new_output_key = f"{new_label}_output"
+
+ # Update Jinja references in blocks for {label}_output pattern
+ if "blocks" in workflow_definition:
+ workflow_definition["blocks"] = _replace_references_in_value(
+ workflow_definition["blocks"], old_output_key, new_output_key
+ )
+ # Also update shorthand {{ label }} references (must be done after _output to avoid partial matches)
+ workflow_definition["blocks"] = _replace_references_in_value(
+ workflow_definition["blocks"], old_label, new_label
+ )
+
+ # Update Jinja references in parameters for {label}_output pattern
+ if "parameters" in workflow_definition:
+ workflow_definition["parameters"] = _replace_references_in_value(
+ workflow_definition["parameters"], old_output_key, new_output_key
+ )
+ # Also update shorthand {{ label }} references
+ workflow_definition["parameters"] = _replace_references_in_value(
+ workflow_definition["parameters"], old_label, new_label
+ )
+ # Also update direct string references (e.g., source_parameter_key)
+ workflow_definition["parameters"] = _replace_direct_string_in_value(
+ workflow_definition["parameters"], old_output_key, new_output_key
+ )
+
+ # Step 4: Update all parameter key references
+ for old_key, new_key in param_key_mapping.items():
+ # Update Jinja references in blocks (e.g., {{ old_key }})
+ if "blocks" in workflow_definition:
+ workflow_definition["blocks"] = _replace_references_in_value(
+ workflow_definition["blocks"], old_key, new_key
+ )
+
+ # Update Jinja references in parameters (e.g., default values that reference other params)
+ if "parameters" in workflow_definition:
+ workflow_definition["parameters"] = _replace_references_in_value(
+ workflow_definition["parameters"], old_key, new_key
+ )
+ # Also update direct string references (e.g., source_parameter_key)
+ workflow_definition["parameters"] = _replace_direct_string_in_value(
+ workflow_definition["parameters"], old_key, new_key
+ )
+
+ # Step 5: Update parameter_keys arrays in blocks
+ if param_key_mapping and "blocks" in workflow_definition:
+ workflow_definition["blocks"] = _update_parameter_keys_in_blocks(
+ workflow_definition["blocks"], param_key_mapping
+ )
+
+ # Step 6: Update finally_block_label if it references an old label
+ if "finally_block_label" in workflow_definition:
+ finally_label = workflow_definition["finally_block_label"]
+ if finally_label in label_mapping:
+ workflow_definition["finally_block_label"] = label_mapping[finally_label]
+
+ # Step 7: Update next_block_label in all blocks
+ def update_next_block_label(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ updated_blocks = []
+ for block in blocks:
+ block = dict(block)
+ if "next_block_label" in block:
+ next_label = block["next_block_label"]
+ if next_label in label_mapping:
+ block["next_block_label"] = label_mapping[next_label]
+ if "loop_blocks" in block and isinstance(block["loop_blocks"], list):
+ block["loop_blocks"] = update_next_block_label(block["loop_blocks"])
+ updated_blocks.append(block)
+ return updated_blocks
+
+ if label_mapping and "blocks" in workflow_definition:
+ workflow_definition["blocks"] = update_next_block_label(workflow_definition["blocks"])
+
+ return workflow_yaml
class WorkflowStatus(StrEnum):
@@ -89,9 +403,26 @@ class ParameterYAML(BaseModel, abc.ABC):
@field_validator("key")
@classmethod
- def validate_no_whitespace(cls, v: str) -> str:
+ def validate_key_is_valid_identifier(cls, v: str) -> str:
+ """Validate that parameter key is a valid Jinja2/Python identifier.
+
+ Parameter keys are used as Jinja2 template variable names. Jinja2 variable names
+ must be valid Python identifiers (letters, digits, underscores; cannot start with digit).
+
+ Characters like '/', '-', '.', etc. are NOT allowed because they are interpreted as
+ operators in Jinja2 templates, causing parsing errors like "'State_' is undefined"
+ when using a key like "State_/_Province".
+ """
if any(char in v for char in [" ", "\t", "\n", "\r"]):
- raise ValueError("Key cannot contain whitespaces")
+ raise ValueError("Key cannot contain whitespace characters")
+
+ if not v.isidentifier():
+ raise ValueError(
+ f"Key '{v}' is not a valid parameter name. "
+ "Parameter keys must be valid Python identifiers "
+ "(only letters, digits, and underscores; cannot start with a digit). "
+ "Characters like '/', '-', '.', etc. are not allowed because they conflict with Jinja2 template syntax."
+ )
return v
@@ -222,8 +553,21 @@ class BlockYAML(BaseModel, abc.ABC):
@field_validator("label")
@classmethod
def validate_label(cls, value: str) -> str:
+ """Validate that block label is a valid Python identifier.
+
+ Block labels are used to create output parameter keys (e.g., '{label}_output')
+ which are then used as Jinja2 template variable names. Therefore, block labels
+ must be valid Python identifiers.
+ """
if not value or not value.strip():
raise ValueError("Block labels cannot be empty.")
+ if not value.isidentifier():
+ raise ValueError(
+ f"Block label '{value}' is not a valid label. "
+ "Block labels must be valid Python identifiers "
+ "(only letters, digits, and underscores; cannot start with a digit). "
+ "Characters like '/', '-', '.', etc. are not allowed because they conflict with Jinja2 template syntax."
+ )
return value
diff --git a/skyvern/utils/strings.py b/skyvern/utils/strings.py
index 892f8d25..f39a72a1 100644
--- a/skyvern/utils/strings.py
+++ b/skyvern/utils/strings.py
@@ -1,5 +1,6 @@
import os
import random
+import re
import string
import uuid
@@ -18,3 +19,30 @@ def is_uuid(string: str) -> bool:
return True
except ValueError:
return False
+
+
+def sanitize_identifier(value: str, default: str = "identifier") -> str:
+ """Sanitizes a string to be a valid Python/Jinja2 identifier.
+
+ Replaces non-alphanumeric characters (except underscores) with underscores,
+ collapses consecutive underscores, strips leading/trailing underscores,
+ and prepends an underscore if the result starts with a digit.
+
+ Args:
+ value: The raw value to sanitize.
+ default: Fallback value if everything is stripped.
+
+ Returns:
+ A sanitized string that is a valid Python identifier.
+ """
+ sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", value)
+ sanitized = re.sub(r"_+", "_", sanitized)
+ sanitized = sanitized.strip("_")
+
+ if sanitized and sanitized[0].isdigit():
+ sanitized = "_" + sanitized
+
+ if not sanitized:
+ sanitized = default
+
+ return sanitized
diff --git a/skyvern/utils/templating.py b/skyvern/utils/templating.py
index d3b02c21..102fe8d9 100644
--- a/skyvern/utils/templating.py
+++ b/skyvern/utils/templating.py
@@ -7,6 +7,28 @@ class Constants:
MissingVariablePattern = var_pattern = r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_.\[\]'\"]*)\s*\}\}"
+def replace_jinja_reference(text: str, old_key: str, new_key: str) -> str:
+ """Replaces jinja-style references in a string.
+
+ Handles patterns like {{oldKey}}, {{oldKey.field}}, {{oldKey | filter}}, {{oldKey[0]}}
+
+ Args:
+ text: The text to search in
+ old_key: The key to replace (without braces)
+ new_key: The new key to use (without braces)
+
+ Returns:
+ The text with references replaced
+ """
+ # Match {{oldKey}} or {{oldKey.something}} or {{oldKey | filter}} or {{oldKey[0]}} etc.
+ # Use negative lookahead to ensure key is not followed by identifier characters,
+ # which prevents matching {{keyOther}} when searching for {{key}}
+ # Capture whitespace after {{ to preserve formatting (e.g., "{{ key }}" stays "{{ newKey }}")
+ escaped_old_key = re.escape(old_key)
+ pattern = rf"\{{\{{(\s*){escaped_old_key}(?![a-zA-Z0-9_])"
+ return re.sub(pattern, rf"{{{{\1{new_key}", text)
+
+
def get_missing_variables(template_source: str, template_data: dict) -> set[str]:
# quick check - catch top-level undefineds
env = Environment(undefined=StrictUndefined)