Added functionality to insert spaces around dicts, lists, and tuples. (#657)

* Added functionality to insert spaces around dicts, lists, and tuples.

* Removed hard-coded values

* Added missing vertical whitespace

* Modified whitespacing around examples

* Use "OpensScope()" and "ClosesScope()"

Co-authored-by: Bill Wendling <[email protected]>
diff --git a/CHANGELOG b/CHANGELOG
index 236ca89..dd0265e 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -2,7 +2,17 @@
 # All notable changes to this project will be documented in this file.
 # This project adheres to [Semantic Versioning](http://semver.org/).
 
-## [0.29.1] UNRELEASED
+## [0.30.0] UNRELEASED
+### Added
+- Added `SPACES_AROUND_LIST_DELIMITERS`, `SPACES_AROUND_DICT_DELIMITERS`,
+  and `SPACES_AROUND_TUPLE_DELIMITERS` to add spaces after the opening-
+  and before the closing-delimiters for lists, dicts, and tuples.
+- Adds `FORCE_MULTILINE_DICT` knob to ensure dictionaries always split,
+  even when shorter than the max line length.
+- New knob `SPACE_INSIDE_BRACKETS` to add spaces inside brackets, braces, and
+  parentheses.
+- New knob `SPACES_AROUND_SUBSCRIPT_COLON` to add spaces around the subscript /
+  slice operator.
 ### Fixed
 - Honor a disable directive at the end of a multiline comment.
 - Don't require splitting before comments in a list when
@@ -11,12 +21,6 @@
 - Don't over-indent a parameter list when not needed. But make sure it is
   properly indented so that it doesn't collide with the lines afterwards.
 - Don't split between two-word comparison operators: "is not", "not in", etc.
-- Adds `FORCE_MULTILINE_DICT` knob to ensure dictionaries always split,
-  even when shorter than the max line length.
-- New knob `SPACE_INSIDE_BRACKETS` to add spaces inside brackets, braces, and
-  parentheses.
-- New knob `SPACES_AROUND_SUBSCRIPT_COLON` to add spaces around the subscript /
-  slice operator.
 
 ## [0.29.0] 2019-11-28
 ### Added
diff --git a/README.rst b/README.rst
index 274e1f8..cc53c89 100644
--- a/README.rst
+++ b/README.rst
@@ -549,6 +549,32 @@
     Set to ``True`` to prefer spaces around the assignment operator for default
     or keyword arguments.
 
+``SPACES_AROUND_DICT_DELIMITERS``
+    Adds a space after the opening '{' and before the ending '}' dict delimiters.
+
+    .. code-block:: python
+
+        {1: 2}
+
+    will be formatted as:
+
+    .. code-block:: python
+
+        { 1: 2 }
+
+``SPACES_AROUND_LIST_DELIMITERS``
+    Adds a space after the opening '[' and before the ending ']' list delimiters.
+
+    .. code-block:: python
+
+        [1, 2]
+
+    will be formatted as:
+    
+    .. code-block:: python
+
+        [ 1, 2 ]
+
 ``SPACES_AROUND_SUBSCRIPT_COLON``
     Use spaces around the subscript / slice operator.  For example:
 
@@ -556,6 +582,19 @@
 
         my_list[1 : 10 : 2]
 
+``SPACES_AROUND_TUPLE_DELIMITERS``
+    Adds a space after the opening '(' and before the ending ')' tuple delimiters.
+
+    .. code-block:: python
+
+        (1, 2, 3)
+
+    will be formatted as:
+
+    .. code-block:: python
+
+        ( 1, 2, 3 )
+
 ``SPACES_BEFORE_COMMENT``
     The number of spaces required before a trailing comment.
     This can be a single value (representing the number of spaces
diff --git a/yapf/yapflib/style.py b/yapf/yapflib/style.py
index 3747941..b8213cd 100644
--- a/yapf/yapflib/style.py
+++ b/yapf/yapflib/style.py
@@ -31,6 +31,11 @@
   return _style[setting_name]
 
 
+def GetOrDefault(setting_name, default_value):
+  """Get a style setting or default value if the setting does not exist."""
+  return _style.get(setting_name, default_value)
+
+
 def Help():
   """Return dict mapping style names to help strings."""
   return _STYLE_HELP
@@ -149,24 +154,8 @@
             transform=Transformation.AVERAGE(window=timedelta(seconds=60)),
             start_ts=now()-timedelta(days=3),
             end_ts=now(),
-        )        # <--- this bracket is dedented and on a separate line"""),
-    INDENT_CLOSING_BRACKETS=textwrap.dedent("""\
-      Put closing brackets on a separate line, indented, if the bracketed
-      expression can't fit in a single line. Applies to all kinds of brackets,
-      including function definitions and calls. For example:
-
-        config = {
-            'key1': 'value1',
-            'key2': 'value2',
-            }        # <--- this bracket is indented and on a separate line
-
-        time_series = self.remote_client.query_entity_counters(
-            entity='dev3246.region1',
-            key='dns.query_latency_tcp',
-            transform=Transformation.AVERAGE(window=timedelta(seconds=60)),
-            start_ts=now()-timedelta(days=3),
-            end_ts=now(),
-            )        # <--- this bracket is indented and on a separate line"""),
+        )        # <--- this bracket is dedented and on a separate line
+      """),
     DISABLE_ENDING_COMMA_HEURISTIC=textwrap.dedent("""\
       Disable the heuristic which places each list element on a separate line
       if the list is comma-terminated."""),
@@ -187,6 +176,24 @@
       The i18n function call names. The presence of this function stops
       reformattting on that line, because the string it has cannot be moved
       away from the i18n comment."""),
+    INDENT_CLOSING_BRACKETS=textwrap.dedent("""\
+      Put closing brackets on a separate line, indented, if the bracketed
+      expression can't fit in a single line. Applies to all kinds of brackets,
+      including function definitions and calls. For example:
+
+        config = {
+            'key1': 'value1',
+            'key2': 'value2',
+            }        # <--- this bracket is indented and on a separate line
+
+        time_series = self.remote_client.query_entity_counters(
+            entity='dev3246.region1',
+            key='dns.query_latency_tcp',
+            transform=Transformation.AVERAGE(window=timedelta(seconds=60)),
+            start_ts=now()-timedelta(days=3),
+            end_ts=now(),
+            )        # <--- this bracket is indented and on a separate line
+        """),
     INDENT_DICTIONARY_VALUE=textwrap.dedent("""\
       Indent the dictionary value if it cannot fit on the same line as the
       dictionary key. For example:
@@ -196,7 +203,8 @@
                 'value1',
             'key2': value1 +
                     value2,
-        }"""),
+        }
+      """),
     INDENT_WIDTH=textwrap.dedent("""\
       The number of columns to use for indentation."""),
     INDENT_BLANK_LINES=textwrap.dedent("""\
@@ -226,10 +234,37 @@
       Use spaces around the power operator."""),
     SPACES_AROUND_DEFAULT_OR_NAMED_ASSIGN=textwrap.dedent("""\
       Use spaces around default or named assigns."""),
+    SPACES_AROUND_DICT_DELIMITERS=textwrap.dedent("""\
+      Adds a space after the opening '{' and before the ending '}' dict delimiters.
+
+        {1: 2}
+
+      will be formatted as:
+
+        { 1: 2 }
+      """),
+    SPACES_AROUND_LIST_DELIMITERS=textwrap.dedent("""\
+      Adds a space after the opening '[' and before the ending ']' list delimiters.
+
+        [1, 2]
+
+      will be formatted as:
+
+        [ 1, 2 ]
+      """),
     SPACES_AROUND_SUBSCRIPT_COLON=textwrap.dedent("""\
       Use spaces around the subscript / slice operator.  For example:
 
         my_list[1 : 10 : 2]
+      """)
+    SPACES_AROUND_TUPLE_DELIMITERS=textwrap.dedent("""\
+      Adds a space after the opening '(' and before the ending ')' tuple delimiters.
+
+        (1, 2, 3)
+
+      will be formatted as:
+
+        ( 1, 2, 3 )
       """),
     SPACES_BEFORE_COMMENT=textwrap.dedent("""\
       The number of spaces required before a trailing comment.
@@ -407,7 +442,10 @@
       SPACE_INSIDE_BRACKETS=False,
       SPACES_AROUND_POWER_OPERATOR=False,
       SPACES_AROUND_DEFAULT_OR_NAMED_ASSIGN=False,
+      SPACES_AROUND_DICT_DELIMITERS=False,
+      SPACES_AROUND_LIST_DELIMITERS=False,
       SPACES_AROUND_SUBSCRIPT_COLON=False,
+      SPACES_AROUND_TUPLE_DELIMITERS=False,
       SPACES_BEFORE_COMMENT=2,
       SPLIT_ARGUMENTS_WHEN_COMMA_TERMINATED=False,
       SPLIT_ALL_COMMA_SEPARATED_VALUES=False,
@@ -592,7 +630,10 @@
     SPACE_INSIDE_BRACKETS=_BoolConverter,
     SPACES_AROUND_POWER_OPERATOR=_BoolConverter,
     SPACES_AROUND_DEFAULT_OR_NAMED_ASSIGN=_BoolConverter,
+    SPACES_AROUND_DICT_DELIMITERS=_BoolConverter,
+    SPACES_AROUND_LIST_DELIMITERS=_BoolConverter,
     SPACES_AROUND_SUBSCRIPT_COLON=_BoolConverter,
+    SPACES_AROUND_TUPLE_DELIMITERS=_BoolConverter,
     SPACES_BEFORE_COMMENT=_IntOrIntListConverter,
     SPLIT_ARGUMENTS_WHEN_COMMA_TERMINATED=_BoolConverter,
     SPLIT_ALL_COMMA_SEPARATED_VALUES=_BoolConverter,
diff --git a/yapf/yapflib/unwrapped_line.py b/yapf/yapflib/unwrapped_line.py
index ec28f9b..38501c0 100644
--- a/yapf/yapflib/unwrapped_line.py
+++ b/yapf/yapflib/unwrapped_line.py
@@ -25,6 +25,8 @@
 from yapf.yapflib import split_penalty
 from yapf.yapflib import style
 
+from lib2to3.fixer_util import syms as python_symbols
+
 
 class UnwrappedLine(object):
   """Represents a single unwrapped line in the output.
@@ -69,7 +71,7 @@
     prev_length = self.first.total_length
     for token in self._tokens[1:]:
       if (token.spaces_required_before == 0 and
-          _SpaceRequiredBetween(prev_token, token)):
+          _SpaceRequiredBetween(prev_token, token, self.disable)):
         token.spaces_required_before = 1
 
       tok_len = len(token.value) if not token.is_pseudo_paren else 0
@@ -265,7 +267,7 @@
   return (token1.is_number or token1.is_name) and token2.is_subscript_colon
 
 
-def _SpaceRequiredBetween(left, right):
+def _SpaceRequiredBetween(left, right, is_line_disabled):
   """Return True if a space is required between the left and right token."""
   lval = left.value
   rval = right.value
@@ -411,6 +413,22 @@
       (lval == '{' and rval == '}')):
     # Empty objects shouldn't be separated by spaces.
     return False
+  if not is_line_disabled and (left.OpensScope() or right.ClosesScope()):
+    if (style.GetOrDefault('SPACES_AROUND_DICT_DELIMITERS', False) and (
+        (lval == '{' and _IsDictListTupleDelimiterTok(left, is_opening=True)) or
+        (rval == '}' and
+         _IsDictListTupleDelimiterTok(right, is_opening=False)))):
+      return True
+    if (style.GetOrDefault('SPACES_AROUND_LIST_DELIMITERS', False) and (
+        (lval == '[' and _IsDictListTupleDelimiterTok(left, is_opening=True)) or
+        (rval == ']' and
+         _IsDictListTupleDelimiterTok(right, is_opening=False)))):
+      return True
+    if (style.GetOrDefault('SPACES_AROUND_TUPLE_DELIMITERS', False) and (
+        (lval == '(' and _IsDictListTupleDelimiterTok(left, is_opening=True)) or
+        (rval == ')' and
+         _IsDictListTupleDelimiterTok(right, is_opening=False)))):
+      return True
   if (lval in pytree_utils.OPENING_BRACKETS and
       rval in pytree_utils.OPENING_BRACKETS):
     # Nested objects' opening brackets shouldn't be separated, unless enabled
@@ -549,6 +567,33 @@
   return None
 
 
+def _IsDictListTupleDelimiterTok(tok, is_opening):
+  assert tok
+
+  if tok.matching_bracket is None:
+    return False
+
+  if is_opening:
+    open_tok = tok
+    close_tok = tok.matching_bracket
+  else:
+    open_tok = tok.matching_bracket
+    close_tok = tok
+
+  # There must be something in between the tokens
+  if open_tok.next_token == close_tok:
+    return False
+
+  assert open_tok.next_token.node
+  assert open_tok.next_token.node.parent
+
+  return open_tok.next_token.node.parent.type in [
+      python_symbols.dictsetmaker,
+      python_symbols.listmaker,
+      python_symbols.testlist_gexp,
+  ]
+
+
 _LOGICAL_OPERATORS = frozenset({'and', 'or'})
 _BITWISE_OPERATORS = frozenset({'&', '|', '^'})
 _ARITHMETIC_OPERATORS = frozenset({'+', '-', '*', '/', '%', '//', '@'})
diff --git a/yapftests/yapf_test.py b/yapftests/yapf_test.py
index 25e4f9b..46fde99 100644
--- a/yapftests/yapf_test.py
+++ b/yapftests/yapf_test.py
@@ -1778,5 +1778,188 @@
     self._Check(unformatted_code, expected_formatted_code)
 
 
+class _SpacesAroundDictListTupleTestImpl(unittest.TestCase):
+
+  @staticmethod
+  def _OwnStyle():
+    my_style = style.CreatePEP8Style()
+    my_style['DISABLE_ENDING_COMMA_HEURISTIC'] = True
+    my_style['SPLIT_ALL_COMMA_SEPARATED_VALUES'] = False
+    my_style['SPLIT_ARGUMENTS_WHEN_COMMA_TERMINATED'] = False
+    return my_style
+
+  def _Check(self, unformatted_code, expected_formatted_code):
+    formatted_code, _ = yapf_api.FormatCode(
+        unformatted_code, style_config=style.SetGlobalStyle(self._OwnStyle()))
+    self.assertEqual(expected_formatted_code, formatted_code)
+
+  def setUp(self):
+    self.maxDiff = None
+
+
+class SpacesAroundDictTest(_SpacesAroundDictListTupleTestImpl):
+
+  @classmethod
+  def _OwnStyle(cls):
+    style = super(SpacesAroundDictTest, cls)._OwnStyle()
+    style['SPACES_AROUND_DICT_DELIMITERS'] = True
+
+    return style
+
+  def testStandard(self):
+    unformatted_code = textwrap.dedent("""\
+      {1 : 2}
+      {k:v for k, v in other.items()}
+      {k for k in [1, 2, 3]}
+
+      # The following statements should not change
+      {}
+      {1 : 2} # yapf: disable
+
+      # yapf: disable
+      {1 : 2}
+      # yapf: enable
+
+      # Dict settings should not impact lists or tuples
+      [1, 2]
+      (3, 4)
+      """)
+    expected_formatted_code = textwrap.dedent("""\
+      { 1: 2 }
+      { k: v for k, v in other.items() }
+      { k for k in [1, 2, 3] }
+      
+      # The following statements should not change
+      {}
+      {1 : 2} # yapf: disable
+
+      # yapf: disable
+      {1 : 2}
+      # yapf: enable
+      
+      # Dict settings should not impact lists or tuples
+      [1, 2]
+      (3, 4)
+      """)
+
+    self._Check(unformatted_code, expected_formatted_code)
+
+
+class SpacesAroundListTest(_SpacesAroundDictListTupleTestImpl):
+
+  @classmethod
+  def _OwnStyle(cls):
+    style = super(SpacesAroundListTest, cls)._OwnStyle()
+    style['SPACES_AROUND_LIST_DELIMITERS'] = True
+
+    return style
+
+  def testStandard(self):
+    unformatted_code = textwrap.dedent("""\
+      [a,b,c]
+      [4,5,]
+      [6, [7, 8], 9]
+      [v for v in [1,2,3] if v & 1]
+
+      # The following statements should not change
+      index[0]
+      index[a, b]
+      []
+      [v for v in [1,2,3] if v & 1] # yapf: disable
+
+      # yapf: disable
+      [a,b,c]
+      [4,5,]
+      # yapf: enable
+
+      # List settings should not impact dicts or tuples
+      {a: b}
+      (1, 2)
+      """)
+    expected_formatted_code = textwrap.dedent("""\
+      [ a, b, c ]
+      [ 4, 5, ]
+      [ 6, [ 7, 8 ], 9 ]
+      [ v for v in [ 1, 2, 3 ] if v & 1 ]
+
+      # The following statements should not change
+      index[0]
+      index[a, b]
+      []
+      [v for v in [1,2,3] if v & 1] # yapf: disable
+      
+      # yapf: disable
+      [a,b,c]
+      [4,5,]
+      # yapf: enable
+      
+      # List settings should not impact dicts or tuples
+      {a: b}
+      (1, 2)
+      """)
+
+    self._Check(unformatted_code, expected_formatted_code)
+
+
+class SpacesAroundTupleTest(_SpacesAroundDictListTupleTestImpl):
+
+  @classmethod
+  def _OwnStyle(cls):
+    style = super(SpacesAroundTupleTest, cls)._OwnStyle()
+    style['SPACES_AROUND_TUPLE_DELIMITERS'] = True
+
+    return style
+
+  def testStandard(self):
+    unformatted_code = textwrap.dedent("""\
+      (0, 1)
+      (2, 3)
+      (4, 5, 6,)
+      func((7, 8), 9)
+
+      # The following statements should not change
+      func(1, 2)
+      (this_func or that_func)(3, 4)
+      if (True and False): pass
+      ()
+
+      (0, 1) # yapf: disable
+
+      # yapf: disable
+      (0, 1)
+      (2, 3)
+      # yapf: enable
+
+      # Tuple settings should not impact dicts or lists
+      {a: b}
+      [3, 4]
+      """)
+    expected_formatted_code = textwrap.dedent("""\
+      ( 0, 1 )
+      ( 2, 3 )
+      ( 4, 5, 6, )
+      func(( 7, 8 ), 9)
+
+      # The following statements should not change
+      func(1, 2)
+      (this_func or that_func)(3, 4)
+      if (True and False): pass
+      ()
+      
+      (0, 1) # yapf: disable
+
+      # yapf: disable
+      (0, 1)
+      (2, 3)
+      # yapf: enable
+      
+      # Tuple settings should not impact dicts or lists
+      {a: b}
+      [3, 4]
+      """)
+
+    self._Check(unformatted_code, expected_formatted_code)
+
+
 if __name__ == '__main__':
   unittest.main()