Skip to content

Commit 86ec79d

Browse files
[BugFix] CLI parser splits comma-separated flag values into separate args (#7420)
* fix: preserve comma-separated values for flagged CLI arguments The CLI argument parser was splitting all comma-separated values into separate positional args before argparse could process them. This caused multi-symbol queries like --symbol AAPL,MSFT,GOOGL to fail with 'args couldn't be interpreted' for all symbols after the first. Flag values are now identified by checking whether the preceding token is a known option string with nargs != 0, and their commas are preserved so the provider receives the original comma-separated string. * test: add coverage for comma-split fix in parse_known_args_and_warn --------- Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
1 parent 3c105ba commit 86ec79d

2 files changed

Lines changed: 151 additions & 5 deletions

File tree

cli/openbb_cli/controllers/base_controller.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ def switch(self, an_input: str) -> list[str]:
201201
# Single command fed, process
202202
else:
203203
try:
204-
(known_args, other_args) = self.parser.parse_known_args(
204+
known_args, other_args = self.parser.parse_known_args(
205205
shlex.split(an_input)
206206
)
207207
except Exception as exc:
@@ -622,7 +622,7 @@ def parse_simple_args(
622622
system_clear()
623623

624624
try:
625-
(ns_parser, l_unknown_args) = parser.parse_known_args(other_args)
625+
ns_parser, l_unknown_args = parser.parse_known_args(other_args)
626626
except SystemExit:
627627
# In case the command has required argument that isn't specified
628628
session.console.print("\n")
@@ -768,19 +768,53 @@ def parse_known_args_and_warn( # pylint: disable=R0917
768768
),
769769
-1,
770770
)
771-
# Split comma-separated arguments, except for the argument at routine_args_index
771+
# Collect indices whose values should NOT be comma-split because
772+
# the provider may accept a comma-separated string (e.g. --symbol
773+
# AAPL,MSFT). Handles --flag value, --flag=value, -f value, and
774+
# multi-value flags (nargs="+" / nargs="*" / nargs=N).
775+
no_split_indices: set[int] = set()
776+
if 0 <= routine_args_index < len(other_args):
777+
no_split_indices.add(routine_args_index)
778+
779+
for i, arg in enumerate(other_args):
780+
if not arg.startswith("-"):
781+
continue
782+
# Handle --flag=value by extracting the flag portion.
783+
flag_part = arg.split("=", 1)[0] if "=" in arg else arg
784+
for action in parser._actions: # pylint: disable=protected-access
785+
if flag_part in action.option_strings and action.nargs != 0:
786+
if "=" in arg:
787+
# Value is embedded in the same token.
788+
no_split_indices.add(i)
789+
elif action.nargs in ("+", "*") or (
790+
isinstance(action.nargs, int) and action.nargs > 1
791+
):
792+
# Multi-value flag: protect all consecutive
793+
# non-flag tokens after the flag.
794+
j = i + 1
795+
while j < len(other_args) and not other_args[j].startswith(
796+
"-"
797+
):
798+
no_split_indices.add(j)
799+
j += 1
800+
# Single-value flag: protect the next token.
801+
elif i + 1 < len(other_args):
802+
no_split_indices.add(i + 1)
803+
break
804+
805+
# Split comma-separated arguments only for positional / unflagged values.
772806
other_args = [
773807
part
774808
for index, arg in enumerate(other_args)
775-
for part in (arg.split(",") if index != routine_args_index else [arg])
809+
for part in ([arg] if index in no_split_indices else arg.split(","))
776810
]
777811

778812
# Check if the action has optional choices, if yes, remove them
779813
for action in parser._actions: # pylint: disable=protected-access
780814
if getattr(action, "optional_choices", None):
781815
action.choices = None
782816

783-
(ns_parser, l_unknown_args) = parser.parse_known_args(other_args)
817+
ns_parser, l_unknown_args = parser.parse_known_args(other_args)
784818

785819
if export_allowed in [
786820
"raw_data_only",

cli/tests/test_controllers_base_controller.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Test the base controller."""
22

3+
import argparse
34
from unittest.mock import MagicMock, patch
45

56
import pytest
@@ -77,3 +78,114 @@ def test_call_exit():
7778
with patch.object(controller, "save_class", MagicMock()):
7879
controller.queue = ["quit"]
7980
controller.call_exit(None)
81+
82+
83+
@pytest.fixture
84+
def mock_base_session():
85+
"""Mock the session for parse_known_args_and_warn tests."""
86+
with patch("openbb_cli.controllers.base_controller.session") as mock_session:
87+
mock_session.settings.USE_CLEAR_AFTER_CMD = False
88+
yield mock_session
89+
90+
91+
def _make_parser(*args_spec):
92+
"""Create an argparse parser from a list of add_argument kwargs."""
93+
parser = argparse.ArgumentParser(add_help=False)
94+
for spec in args_spec:
95+
flags = spec.pop("flags")
96+
parser.add_argument(*flags, **spec)
97+
return parser
98+
99+
100+
def test_comma_split_flagged_value_not_split(mock_base_session):
101+
"""Simple test: --symbol AAPL,MSFT must stay as one value."""
102+
parser = _make_parser({"flags": ["--symbol", "-s"], "dest": "symbol", "type": str})
103+
result = BaseController.parse_known_args_and_warn(parser, ["--symbol", "AAPL,MSFT"])
104+
assert result is not None
105+
assert result.symbol == "AAPL,MSFT"
106+
107+
108+
def test_comma_split_short_flag_not_split(mock_base_session):
109+
"""Short flag -s AAPL,MSFT must also stay as one value."""
110+
parser = _make_parser({"flags": ["--symbol", "-s"], "dest": "symbol", "type": str})
111+
result = BaseController.parse_known_args_and_warn(parser, ["-s", "AAPL,MSFT"])
112+
assert result is not None
113+
assert result.symbol == "AAPL,MSFT"
114+
115+
116+
def test_comma_split_equals_syntax_not_split(mock_base_session):
117+
"""--symbol=AAPL,MSFT must not be split."""
118+
parser = _make_parser({"flags": ["--symbol", "-s"], "dest": "symbol", "type": str})
119+
result = BaseController.parse_known_args_and_warn(parser, ["--symbol=AAPL,MSFT"])
120+
assert result is not None
121+
assert result.symbol == "AAPL,MSFT"
122+
123+
124+
def test_comma_split_nargs_plus_all_values_protected(mock_base_session):
125+
"""nargs='+': all consecutive values after --symbols are protected."""
126+
parser = _make_parser(
127+
{"flags": ["--symbols"], "dest": "symbols", "nargs": "+", "type": str}
128+
)
129+
result = BaseController.parse_known_args_and_warn(
130+
parser, ["--symbols", "AAPL,MSFT", "GOOG,AMZN"]
131+
)
132+
assert result is not None
133+
assert result.symbols == ["AAPL,MSFT", "GOOG,AMZN"]
134+
135+
136+
def test_comma_split_nargs_star_values_protected(mock_base_session):
137+
"""nargs='*': consecutive values after --tags are protected."""
138+
parser = _make_parser(
139+
{"flags": ["--tags"], "dest": "tags", "nargs": "*", "type": str}
140+
)
141+
result = BaseController.parse_known_args_and_warn(parser, ["--tags", "a,b", "c,d"])
142+
assert result is not None
143+
assert result.tags == ["a,b", "c,d"]
144+
145+
146+
def test_comma_split_nargs_int_values_protected(mock_base_session):
147+
"""nargs=2: both values after --pair are protected."""
148+
parser = _make_parser(
149+
{"flags": ["--pair"], "dest": "pair", "nargs": 2, "type": str}
150+
)
151+
result = BaseController.parse_known_args_and_warn(parser, ["--pair", "a,b", "c,d"])
152+
assert result is not None
153+
assert result.pair == ["a,b", "c,d"]
154+
155+
156+
def test_comma_split_store_true_not_confused(mock_base_session):
157+
"""store_true flags (nargs=0) should not protect the next token."""
158+
parser = _make_parser(
159+
{"flags": ["--symbol", "-s"], "dest": "symbol", "type": str},
160+
{"flags": ["--raw"], "dest": "raw", "action": "store_true", "default": False},
161+
)
162+
result = BaseController.parse_known_args_and_warn(
163+
parser, ["--raw", "--symbol", "AAPL,MSFT"]
164+
)
165+
assert result is not None
166+
assert result.raw is True
167+
assert result.symbol == "AAPL,MSFT"
168+
169+
170+
def test_comma_split_no_comma_values_unchanged(mock_base_session):
171+
"""Values without commas pass through unaffected."""
172+
parser = _make_parser({"flags": ["--symbol", "-s"], "dest": "symbol", "type": str})
173+
result = BaseController.parse_known_args_and_warn(parser, ["--symbol", "AAPL"])
174+
assert result is not None
175+
assert result.symbol == "AAPL"
176+
177+
178+
def test_comma_split_multiple_flags_each_protected(mock_base_session):
179+
"""Multiple flags each protect their own values independently."""
180+
parser = _make_parser(
181+
{"flags": ["--symbol", "-s"], "dest": "symbol", "type": str},
182+
{"flags": ["--raw"], "dest": "raw", "action": "store_true", "default": False},
183+
{"flags": ["--provider"], "dest": "provider", "type": str},
184+
)
185+
result = BaseController.parse_known_args_and_warn(
186+
parser,
187+
["--symbol", "AAPL,MSFT", "--provider", "yfinance,polygon"],
188+
)
189+
assert result is not None
190+
assert result.symbol == "AAPL,MSFT"
191+
assert result.provider == "yfinance,polygon"

0 commit comments

Comments
 (0)