Skip to content

Commit ae457da

Browse files
authored
feat: add generic --feature-flag CLI arg and --list-feature-flags registry (#13685)
1 parent 413e250 commit ae457da

4 files changed

Lines changed: 186 additions & 3 deletions

File tree

comfy/cli_args.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,8 @@ def is_valid_directory(path: str) -> str:
238238
)
239239
parser.add_argument("--database-url", type=str, default=f"sqlite:///{database_default_path}", help="Specify the database URL, e.g. for an in-memory database you can use 'sqlite:///:memory:'.")
240240
parser.add_argument("--enable-assets", action="store_true", help="Enable the assets system (API routes, database synchronization, and background scanning).")
241+
parser.add_argument("--feature-flag", type=str, action='append', default=[], metavar="KEY[=VALUE]", help="Set a server feature flag. Use KEY=VALUE to set an explicit value, or bare KEY to set it to true. Can be specified multiple times. Boolean values (true/false) and numbers are auto-converted. Examples: --feature-flag show_signin_button=true or --feature-flag show_signin_button")
242+
parser.add_argument("--list-feature-flags", action="store_true", help="Print the registry of known CLI-settable feature flags as JSON and exit.")
241243

242244
if comfy.options.args_parsing:
243245
args = parser.parse_args()

comfy_api/feature_flags.py

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,107 @@
55
allowing graceful protocol evolution while maintaining backward compatibility.
66
"""
77

8-
from typing import Any
8+
import logging
9+
from typing import Any, TypedDict
910

1011
from comfy.cli_args import args
1112

13+
14+
class FeatureFlagInfo(TypedDict):
15+
type: str
16+
default: Any
17+
description: str
18+
19+
20+
# Registry of known CLI-settable feature flags.
21+
# Launchers can query this via --list-feature-flags to discover valid flags.
22+
CLI_FEATURE_FLAG_REGISTRY: dict[str, FeatureFlagInfo] = {
23+
"show_signin_button": {
24+
"type": "bool",
25+
"default": False,
26+
"description": "Show the sign-in button in the frontend even when not signed in",
27+
},
28+
}
29+
30+
31+
def _coerce_bool(v: str) -> bool:
32+
"""Strict bool coercion: only 'true'/'false' (case-insensitive).
33+
34+
Anything else raises ValueError so the caller can warn and drop the flag,
35+
rather than silently treating typos like 'ture' or 'yes' as False.
36+
"""
37+
lower = v.lower()
38+
if lower == "true":
39+
return True
40+
if lower == "false":
41+
return False
42+
raise ValueError(f"expected 'true' or 'false', got {v!r}")
43+
44+
45+
_COERCE_FNS: dict[str, Any] = {
46+
"bool": _coerce_bool,
47+
"int": lambda v: int(v),
48+
"float": lambda v: float(v),
49+
}
50+
51+
52+
def _coerce_flag_value(key: str, raw_value: str) -> Any:
53+
"""Coerce a raw string value using the registry type, or keep as string.
54+
55+
Returns the raw string if the key is unregistered or the type is unknown.
56+
Raises ValueError/TypeError if the key is registered with a known type but
57+
the value cannot be coerced; callers are expected to warn and drop the flag.
58+
"""
59+
info = CLI_FEATURE_FLAG_REGISTRY.get(key)
60+
if info is None:
61+
return raw_value
62+
coerce = _COERCE_FNS.get(info["type"])
63+
if coerce is None:
64+
return raw_value
65+
return coerce(raw_value)
66+
67+
68+
def _parse_cli_feature_flags() -> dict[str, Any]:
69+
"""Parse --feature-flag key=value pairs from CLI args into a dict.
70+
71+
Items without '=' default to the value 'true' (bare flag form).
72+
Flags whose value cannot be coerced to the registered type are dropped
73+
with a warning, so a typo like '--feature-flag some_bool=ture' does not
74+
silently take effect as the wrong value.
75+
"""
76+
result: dict[str, Any] = {}
77+
for item in getattr(args, "feature_flag", []):
78+
key, sep, raw_value = item.partition("=")
79+
key = key.strip()
80+
if not key:
81+
continue
82+
if not sep:
83+
raw_value = "true"
84+
try:
85+
result[key] = _coerce_flag_value(key, raw_value.strip())
86+
except (ValueError, TypeError) as e:
87+
info = CLI_FEATURE_FLAG_REGISTRY.get(key, {})
88+
logging.warning(
89+
"Could not coerce --feature-flag %s=%r to %s (%s); dropping flag.",
90+
key, raw_value.strip(), info.get("type", "?"), e,
91+
)
92+
return result
93+
94+
1295
# Default server capabilities
13-
SERVER_FEATURE_FLAGS: dict[str, Any] = {
96+
_CORE_FEATURE_FLAGS: dict[str, Any] = {
1497
"supports_preview_metadata": True,
1598
"max_upload_size": args.max_upload_size * 1024 * 1024, # Convert MB to bytes
1699
"extension": {"manager": {"supports_v4": True}},
17100
"node_replacements": True,
18101
"assets": args.enable_assets,
19102
}
20103

104+
# CLI-provided flags cannot overwrite core flags
105+
_cli_flags = {k: v for k, v in _parse_cli_feature_flags().items() if k not in _CORE_FEATURE_FLAGS}
106+
107+
SERVER_FEATURE_FLAGS: dict[str, Any] = {**_CORE_FEATURE_FLAGS, **_cli_flags}
108+
21109

22110
def get_connection_feature(
23111
sockets_metadata: dict[str, dict[str, Any]],

main.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import comfy.options
22
comfy.options.enable_args_parsing()
33

4+
from comfy.cli_args import args
5+
6+
if args.list_feature_flags:
7+
import json
8+
from comfy_api.feature_flags import CLI_FEATURE_FLAG_REGISTRY
9+
print(json.dumps(CLI_FEATURE_FLAG_REGISTRY, indent=2)) # noqa: T201
10+
raise SystemExit(0)
11+
412
import os
513
import importlib.util
614
import shutil
715
import importlib.metadata
816
import folder_paths
917
import time
10-
from comfy.cli_args import args, enables_dynamic_vram
18+
from comfy.cli_args import enables_dynamic_vram
1119
from app.logger import setup_logger
1220
setup_logger(log_level=args.verbose, use_stdout=args.log_stdout)
1321

tests-unit/feature_flags_test.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
"""Tests for feature flags functionality."""
22

3+
import pytest
4+
35
from comfy_api.feature_flags import (
46
get_connection_feature,
57
supports_feature,
68
get_server_features,
9+
CLI_FEATURE_FLAG_REGISTRY,
710
SERVER_FEATURE_FLAGS,
11+
_coerce_flag_value,
12+
_parse_cli_feature_flags,
813
)
914

1015

@@ -96,3 +101,83 @@ def test_empty_feature_flags_dict(self):
96101
result = get_connection_feature(sockets_metadata, "sid1", "any_feature")
97102
assert result is False
98103
assert supports_feature(sockets_metadata, "sid1", "any_feature") is False
104+
105+
106+
class TestCoerceFlagValue:
107+
"""Test suite for _coerce_flag_value."""
108+
109+
def test_registered_bool_true(self):
110+
assert _coerce_flag_value("show_signin_button", "true") is True
111+
assert _coerce_flag_value("show_signin_button", "True") is True
112+
113+
def test_registered_bool_false(self):
114+
assert _coerce_flag_value("show_signin_button", "false") is False
115+
assert _coerce_flag_value("show_signin_button", "FALSE") is False
116+
117+
def test_unregistered_key_stays_string(self):
118+
assert _coerce_flag_value("unknown_flag", "true") == "true"
119+
assert _coerce_flag_value("unknown_flag", "42") == "42"
120+
121+
def test_bool_typo_raises(self):
122+
"""Strict bool: typos like 'ture' or 'yes' must raise so the flag can be dropped."""
123+
with pytest.raises(ValueError):
124+
_coerce_flag_value("show_signin_button", "ture")
125+
with pytest.raises(ValueError):
126+
_coerce_flag_value("show_signin_button", "yes")
127+
with pytest.raises(ValueError):
128+
_coerce_flag_value("show_signin_button", "1")
129+
with pytest.raises(ValueError):
130+
_coerce_flag_value("show_signin_button", "")
131+
132+
def test_failed_int_coercion_raises(self, monkeypatch):
133+
"""Malformed values for typed flags must raise; caller decides what to do."""
134+
monkeypatch.setitem(
135+
CLI_FEATURE_FLAG_REGISTRY,
136+
"test_int_flag",
137+
{"type": "int", "default": 0, "description": "test"},
138+
)
139+
with pytest.raises(ValueError):
140+
_coerce_flag_value("test_int_flag", "not_a_number")
141+
142+
143+
class TestParseCliFeatureFlags:
144+
"""Test suite for _parse_cli_feature_flags."""
145+
146+
def test_single_flag(self, monkeypatch):
147+
monkeypatch.setattr("comfy_api.feature_flags.args", type("Args", (), {"feature_flag": ["show_signin_button=true"]})())
148+
result = _parse_cli_feature_flags()
149+
assert result == {"show_signin_button": True}
150+
151+
def test_missing_equals_defaults_to_true(self, monkeypatch):
152+
"""Bare flag without '=' is treated as the string 'true' (and coerced if registered)."""
153+
monkeypatch.setattr("comfy_api.feature_flags.args", type("Args", (), {"feature_flag": ["show_signin_button", "valid=1"]})())
154+
result = _parse_cli_feature_flags()
155+
assert result == {"show_signin_button": True, "valid": "1"}
156+
157+
def test_empty_key_skipped(self, monkeypatch):
158+
monkeypatch.setattr("comfy_api.feature_flags.args", type("Args", (), {"feature_flag": ["=value", "valid=1"]})())
159+
result = _parse_cli_feature_flags()
160+
assert result == {"valid": "1"}
161+
162+
def test_invalid_bool_value_dropped(self, monkeypatch, caplog):
163+
"""A typo'd bool value must be dropped entirely, not silently set to False
164+
and not stored as a raw string. A warning must be logged."""
165+
monkeypatch.setattr(
166+
"comfy_api.feature_flags.args",
167+
type("Args", (), {"feature_flag": ["show_signin_button=ture", "valid=1"]})(),
168+
)
169+
with caplog.at_level("WARNING"):
170+
result = _parse_cli_feature_flags()
171+
assert result == {"valid": "1"}
172+
assert "show_signin_button" not in result
173+
assert any("show_signin_button" in r.message and "drop" in r.message.lower() for r in caplog.records)
174+
175+
176+
class TestCliFeatureFlagRegistry:
177+
"""Test suite for the CLI feature flag registry."""
178+
179+
def test_registry_entries_have_required_fields(self):
180+
for key, info in CLI_FEATURE_FLAG_REGISTRY.items():
181+
assert "type" in info, f"{key} missing 'type'"
182+
assert "default" in info, f"{key} missing 'default'"
183+
assert "description" in info, f"{key} missing 'description'"

0 commit comments

Comments
 (0)