From 925c959773721be6be085157fb82c7dc0b5a71ed Mon Sep 17 00:00:00 2001 From: Yadidiah K Date: Sun, 5 Jul 2026 18:46:00 +0400 Subject: [PATCH] fix: parse hyphenated strings correctly inside container literals When a hyphenated string like 'foo-bar' appears inside a tuple, list, set, or dict (e.g. '(foo-bar,baz)'), ast.parse turns the hyphen into a BinOp(Sub) node. The AST walker only replaced bare Name nodes, leaving the BinOp unresolved. ast.literal_eval then failed on the BinOp and the entire container fell back to a plain string instead of a tuple/list/dict. Add _BinOpSubToStr to recursively convert a Name-Sub-Name chain back to a hyphenated string, and call it in the walker alongside _Replacement. This handles multi-segment names like 'a-b-c' too. Fixes #561 --- fire/parser.py | 30 ++++++++++++++++++++++++++++++ fire/parser_test.py | 21 +++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/fire/parser.py b/fire/parser.py index a335cc2c..d745209e 100644 --- a/fire/parser.py +++ b/fire/parser.py @@ -105,10 +105,18 @@ def _LiteralEval(value): for index, subchild in enumerate(child): if isinstance(subchild, ast.Name): child[index] = _Replacement(subchild) + elif isinstance(subchild, ast.BinOp): + str_val = _BinOpSubToStr(subchild) + if str_val is not None: + child[index] = _StrNode(str_val) elif isinstance(child, ast.Name): replacement = _Replacement(child) setattr(node, field, replacement) + elif isinstance(child, ast.BinOp): + str_val = _BinOpSubToStr(child) + if str_val is not None: + setattr(node, field, _StrNode(str_val)) # ast.literal_eval supports the following types: # strings, bytes, numbers, tuples, lists, dicts, sets, booleans, and None @@ -116,6 +124,28 @@ def _LiteralEval(value): return ast.literal_eval(root) +def _BinOpSubToStr(node): + """Converts a BinOp subtraction chain of Name nodes to a hyphenated string. + + Handles patterns like Name-Name and Name-Name-Name that appear when a + hyphenated string (e.g. 'foo-bar') is used inside a container literal + such as a tuple, list, set, or dict. + + Args: + node: An AST node. + Returns: + The hyphenated string if node is a Sub chain of Names, else None. + """ + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Sub): + left = _BinOpSubToStr(node.left) + right = _BinOpSubToStr(node.right) + if left is not None and right is not None: + return '{left}-{right}'.format(left=left, right=right) + return None + + def _Replacement(node): """Returns a node to use in place of the supplied node in the AST. diff --git a/fire/parser_test.py b/fire/parser_test.py index a404eea2..0338d8ed 100644 --- a/fire/parser_test.py +++ b/fire/parser_test.py @@ -136,5 +136,26 @@ def testDefaultParseValueIgnoreBinOp(self): self.assertEqual(parser.DefaultParseValue('2017-10-10'), '2017-10-10') self.assertEqual(parser.DefaultParseValue('1+1'), '1+1') + def testDefaultParseValueHyphenatedStringsInTuple(self): + self.assertEqual( + parser.DefaultParseValue('(foo-bar,baz)'), ('foo-bar', 'baz')) + self.assertEqual( + parser.DefaultParseValue('(a-b-c,d)'), ('a-b-c', 'd')) + self.assertEqual( + parser.DefaultParseValue('(foo-bar,hello-world)'), + ('foo-bar', 'hello-world')) + + def testDefaultParseValueHyphenatedStringsInList(self): + self.assertEqual( + parser.DefaultParseValue('[foo-bar,baz]'), ['foo-bar', 'baz']) + self.assertEqual( + parser.DefaultParseValue('[a-b-c,d]'), ['a-b-c', 'd']) + + def testDefaultParseValueHyphenatedStringsInDict(self): + self.assertEqual( + parser.DefaultParseValue('{foo-bar:baz}'), {'foo-bar': 'baz'}) + self.assertEqual( + parser.DefaultParseValue('{key:foo-bar}'), {'key': 'foo-bar'}) + if __name__ == '__main__': testutils.main()