Skip to content

Commit 5b913f0

Browse files
committed
feat: add minLength/maxLength validation for String inputs
1 parent e4b0bb8 commit 5b913f0

4 files changed

Lines changed: 152 additions & 5 deletions

File tree

comfy_api/latest/_io.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,18 +326,23 @@ class Input(WidgetInput):
326326
'''String input.'''
327327
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None,
328328
multiline=False, placeholder: str=None, default: str=None, dynamic_prompts: bool=None,
329-
socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None, advanced: bool=None):
329+
socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None, advanced: bool=None,
330+
min_length: int=None, max_length: int=None):
330331
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link, advanced)
331332
self.multiline = multiline
332333
self.placeholder = placeholder
333334
self.dynamic_prompts = dynamic_prompts
335+
self.min_length = min_length
336+
self.max_length = max_length
334337
self.default: str
335338

336339
def as_dict(self):
337340
return super().as_dict() | prune_dict({
338341
"multiline": self.multiline,
339342
"placeholder": self.placeholder,
340343
"dynamicPrompts": self.dynamic_prompts,
344+
"minLength": self.min_length,
345+
"maxLength": self.max_length,
341346
})
342347

343348
@comfytype(io_type="COMBO")

execution.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ async def get(self, node_id):
8181
return self.is_changed[node_id]
8282

8383
# Intentionally do not use cached outputs here. We only want constants in IS_CHANGED
84-
input_data_all, _, v3_data = get_input_data(node["inputs"], class_def, node_id, None)
84+
input_data_all, _, v3_data, _ = get_input_data(node["inputs"], class_def, node_id, None)
8585
try:
8686
is_changed = await _async_map_node_over_list(self.prompt_id, node_id, class_def, input_data_all, is_changed_name, v3_data=v3_data)
8787
is_changed = await resolve_map_node_over_list_results(is_changed)
@@ -213,7 +213,35 @@ def mark_missing():
213213
if h[x] == "API_KEY_COMFY_ORG":
214214
input_data_all[x] = [extra_data.get("api_key_comfy_org", None)]
215215
v3_data["hidden_inputs"] = hidden_inputs_v3
216-
return input_data_all, missing_keys, v3_data
216+
return input_data_all, missing_keys, v3_data, valid_inputs
217+
218+
def validate_resolved_inputs(input_data_all, class_def, valid_inputs):
219+
"""Validate resolved input values against schema constraints.
220+
221+
This is needed because validate_inputs() only sees direct widget values.
222+
Linked inputs aren't resolved during validate_inputs(), so this runs after resolution to catch any violations.
223+
"""
224+
for x, values in input_data_all.items():
225+
input_type, input_category, extra_info = get_input_info(class_def, x, valid_inputs)
226+
if input_type != "STRING":
227+
continue
228+
min_length = extra_info.get("minLength")
229+
max_length = extra_info.get("maxLength")
230+
if min_length is None and max_length is None:
231+
continue
232+
for val in values:
233+
if val is None or not isinstance(val, str):
234+
continue
235+
if min_length is not None and len(val) < min_length:
236+
raise ValueError(
237+
f"Input '{x}': value length {len(val)} is shorter than "
238+
f"minimum length of {min_length}"
239+
)
240+
if max_length is not None and len(val) > max_length:
241+
raise ValueError(
242+
f"Input '{x}': value length {len(val)} is longer than "
243+
f"maximum length of {max_length}"
244+
)
217245

218246
map_node_over_list = None #Don't hook this please
219247

@@ -469,7 +497,7 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
469497
has_subgraph = False
470498
else:
471499
get_progress_state().start_progress(unique_id)
472-
input_data_all, missing_keys, v3_data = get_input_data(inputs, class_def, unique_id, execution_list, dynprompt, extra_data)
500+
input_data_all, missing_keys, v3_data, valid_inputs = get_input_data(inputs, class_def, unique_id, execution_list, dynprompt, extra_data)
473501
if server.client_id is not None:
474502
server.last_node_id = display_node_id
475503
server.send_sync("executing", { "node": unique_id, "display_node": display_node_id, "prompt_id": prompt_id }, server.client_id)
@@ -498,6 +526,8 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
498526
execution_list.make_input_strong_link(unique_id, i)
499527
return (ExecutionResult.PENDING, None, None)
500528

529+
validate_resolved_inputs(input_data_all, class_def, valid_inputs)
530+
501531
def execution_block_cb(block):
502532
if block.message is not None:
503533
mes = {
@@ -940,6 +970,34 @@ async def validate_inputs(prompt_id, prompt, item, validated):
940970
errors.append(error)
941971
continue
942972

973+
if input_type == "STRING":
974+
if "minLength" in extra_info and len(val) < extra_info["minLength"]:
975+
error = {
976+
"type": "value_shorter_than_min_length",
977+
"message": "Value length {} shorter than min length of {}".format(len(val), extra_info["minLength"]),
978+
"details": f"{x}",
979+
"extra_info": {
980+
"input_name": x,
981+
"input_config": info,
982+
"received_value": val,
983+
}
984+
}
985+
errors.append(error)
986+
continue
987+
if "maxLength" in extra_info and len(val) > extra_info["maxLength"]:
988+
error = {
989+
"type": "value_longer_than_max_length",
990+
"message": "Value length {} longer than max length of {}".format(len(val), extra_info["maxLength"]),
991+
"details": f"{x}",
992+
"extra_info": {
993+
"input_name": x,
994+
"input_config": info,
995+
"received_value": val,
996+
}
997+
}
998+
errors.append(error)
999+
continue
1000+
9431001
if isinstance(input_type, list) or input_type == io.Combo.io_type:
9441002
if input_type == io.Combo.io_type:
9451003
combo_options = extra_info.get("options", [])
@@ -971,7 +1029,7 @@ async def validate_inputs(prompt_id, prompt, item, validated):
9711029
continue
9721030

9731031
if len(validate_function_inputs) > 0 or validate_has_kwargs:
974-
input_data_all, _, v3_data = get_input_data(inputs, obj_class, unique_id)
1032+
input_data_all, _, v3_data, _ = get_input_data(inputs, obj_class, unique_id)
9751033
input_filtered = {}
9761034
for x in input_data_all:
9771035
if x in validate_function_inputs or validate_has_kwargs:

tests/execution/test_execution.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,3 +1011,49 @@ def test_get_job_not_found(
10111011
"""Test getting a non-existent job returns 404"""
10121012
job = client.get_job("nonexistent-job-id")
10131013
assert job is None, "Non-existent job should return None"
1014+
1015+
1016+
@pytest.mark.parametrize("text, expect_error", [
1017+
("hello", False), # 5 chars, within [3, 10]
1018+
("abc", False), # 3 chars, exact min boundary
1019+
("abcdefghij", False), # 10 chars, exact max boundary
1020+
("ab", True), # 2 chars, below min
1021+
("abcdefghijk", True), # 11 chars, above max
1022+
("", True), # 0 chars, below min
1023+
])
1024+
def test_string_length_widget_validation(self, text, expect_error, client: ComfyClient, builder: GraphBuilder):
1025+
"""Test minLength/maxLength validation for direct widget values (validate_inputs path)."""
1026+
g = builder
1027+
node = g.node("StubStringWithLength", text=text)
1028+
g.node("SaveImage", images=node.out(0))
1029+
if expect_error:
1030+
with pytest.raises(urllib.error.HTTPError) as exc_info:
1031+
client.run(g)
1032+
assert exc_info.value.code == 400
1033+
else:
1034+
client.run(g)
1035+
1036+
1037+
@pytest.mark.parametrize("text, expect_error", [
1038+
("hello", False), # 5 chars, within [3, 10]
1039+
("abc", False), # 3 chars, exact min boundary
1040+
("abcdefghij", False), # 10 chars, exact max boundary
1041+
("ab", True), # 2 chars, below min
1042+
("abcdefghijk", True), # 11 chars, above max
1043+
("", True), # 0 chars, below min
1044+
])
1045+
def test_string_length_linked_validation(self, text, expect_error, client: ComfyClient, builder: GraphBuilder):
1046+
"""Test minLength/maxLength validation for linked inputs (validate_resolved_inputs path)."""
1047+
g = builder
1048+
str_node = g.node("StubStringOutput", value=text)
1049+
node = g.node("StubStringWithLength", text=str_node.out(0))
1050+
g.node("SaveImage", images=node.out(0))
1051+
1052+
if expect_error:
1053+
try:
1054+
client.run(g)
1055+
assert False, "Should have raised an error"
1056+
except Exception as e:
1057+
assert 'prompt_id' in e.args[0], f"Did not get proper error message: {e}"
1058+
else:
1059+
client.run(g)

tests/execution/testing_nodes/testing-pack/stubs.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,17 +113,55 @@ def INPUT_TYPES(cls):
113113
def stub_float(self, value):
114114
return (value,)
115115

116+
class StubStringOutput:
117+
@classmethod
118+
def INPUT_TYPES(cls):
119+
return {
120+
"required": {
121+
"value": ("STRING", {"default": ""}),
122+
},
123+
}
124+
125+
RETURN_TYPES = ("STRING",)
126+
FUNCTION = "stub_string"
127+
128+
CATEGORY = "Testing/Stub Nodes"
129+
130+
def stub_string(self, value):
131+
return (value,)
132+
133+
class StubStringWithLength:
134+
@classmethod
135+
def INPUT_TYPES(cls):
136+
return {
137+
"required": {
138+
"text": ("STRING", {"default": "hello", "minLength": 3, "maxLength": 10}),
139+
},
140+
}
141+
142+
RETURN_TYPES = ("IMAGE",)
143+
FUNCTION = "stub_string_with_length"
144+
145+
CATEGORY = "Testing/Stub Nodes"
146+
147+
def stub_string_with_length(self, text):
148+
return (torch.zeros(1, 64, 64, 3),)
149+
116150
TEST_STUB_NODE_CLASS_MAPPINGS = {
117151
"StubImage": StubImage,
118152
"StubConstantImage": StubConstantImage,
119153
"StubMask": StubMask,
120154
"StubInt": StubInt,
121155
"StubFloat": StubFloat,
156+
"StubStringOutput": StubStringOutput,
157+
"StubStringWithLength": StubStringWithLength,
122158
}
123159
TEST_STUB_NODE_DISPLAY_NAME_MAPPINGS = {
124160
"StubImage": "Stub Image",
125161
"StubConstantImage": "Stub Constant Image",
126162
"StubMask": "Stub Mask",
127163
"StubInt": "Stub Int",
128164
"StubFloat": "Stub Float",
165+
"StubStringOutput": "Stub String Output",
166+
"StubStringWithLength": "Stub String With Length",
129167
}

0 commit comments

Comments
 (0)