Repository: streamlit/streamlit

Issue:
`streamlit config show` newlines are not printing correctly
### Steps to reproduce

```
streamlit config show
```

**Expected behavior:**

Sections that use newlines for meaningful formatting (e.g. to make up lists) should have their newlines honored. For example, `browser.serverAddress` should look like this:
```
# Internet address where users should point their browsers in order to connect to the app. Can be IP address or DNS name and path.
#
# This is used to:
# - Set the correct URL for CORS and XSRF protection purposes.
# - Show the URL on the terminal
# - Open the browser
# - Tell the browser where to connect to the server when in liveSave mode.
#
# Default: 'localhost'
serverAddress = "localhost"
```

**Actual behavior:**

The section above looks like this in reality:
```
# Internet address where users should point their browsers in order to connect to the app. Can be IP address or DNS name and path.
# This is used to: - Set the correct URL for CORS and XSRF protection purposes. - Show the URL on the terminal - Open the browser - Tell the browser where to connect to the server when in liveSave mode.
# Default: 'localhost'
serverAddress = "localhost"
```


### Is this a regression?

no

### Debug info

- Streamlit version: 0.76.0

### Additional information

I'm pretty sure I wrote the code that is doing the wrong thing :)
Should be somewhere around `config.py` at `_clean_paragraphs()`.


Discussion:

Resolving patch:
diff --git a/lib/streamlit/config_util.py b/lib/streamlit/config_util.py
--- a/lib/streamlit/config_util.py
+++ b/lib/streamlit/config_util.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import re
 from typing import Dict
 
 import click
@@ -56,10 +57,10 @@ def show_config(
     )
 
     def append_desc(text):
-        out.append(click.style(text, bold=True))
+        out.append("# " + click.style(text, bold=True))
 
     def append_comment(text):
-        out.append(click.style(text))
+        out.append("# " + click.style(text))
 
     def append_section(text):
         out.append(click.style(text, bold=True, fg="green"))
@@ -67,16 +68,13 @@ def append_section(text):
     def append_setting(text):
         out.append(click.style(text, fg="green"))
 
-    def append_newline():
-        out.append("")
-
     for section, section_description in section_descriptions.items():
         if section in SKIP_SECTIONS:
             continue
 
-        append_newline()
+        out.append("")
         append_section("[%s]" % section)
-        append_newline()
+        out.append("")
 
         for key, option in config_options.items():
             if option.section != section:
@@ -91,40 +89,57 @@ def append_newline():
             key = option.key.split(".")[1]
             description_paragraphs = _clean_paragraphs(option.description)
 
-            for i, txt in enumerate(description_paragraphs):
-                if i == 0:
-                    append_desc("# %s" % txt)
-                else:
-                    append_comment("# %s" % txt)
+            last_paragraph_idx = len(description_paragraphs) - 1
+
+            for i, paragraph in enumerate(description_paragraphs):
+                # Split paragraph into lines
+                lines = paragraph.rstrip().split(
+                    "\n"
+                )  # Remove trailing newline characters
+
+                # If the first line is empty, remove it
+                if lines and not lines[0].strip():
+                    lines = lines[1:]
+
+                # Choose function based on whether it's the first paragraph or not
+                append_func = append_desc if i == 0 else append_comment
+
+                # Add comment character to each line and add to out
+                for line in lines:
+                    append_func(line.lstrip())
+
+                # # Add a line break after a paragraph only if it's not the last paragraph
+                if i != last_paragraph_idx:
+                    out.append("")
 
             toml_default = toml.dumps({"default": option.default_val})
             toml_default = toml_default[10:].strip()
 
             if len(toml_default) > 0:
-                append_comment("# Default: %s" % toml_default)
+                # Ensure a line break before appending "Default" comment, if not already there
+                if out[-1] != "":
+                    out.append("")
+                append_comment("Default: %s" % toml_default)
             else:
                 # Don't say "Default: (unset)" here because this branch applies
                 # to complex config settings too.
                 pass
 
             if option.deprecated:
-                append_comment("#")
-                append_comment("# " + click.style("DEPRECATED.", fg="yellow"))
-                append_comment(
-                    "# %s" % "\n".join(_clean_paragraphs(option.deprecation_text))
-                )
+                append_comment(click.style("DEPRECATED.", fg="yellow"))
+                for line in _clean_paragraphs(option.deprecation_text):
+                    append_comment(line)
                 append_comment(
-                    "# This option will be removed on or after %s."
+                    "This option will be removed on or after %s."
                     % option.expiration_date
                 )
-                append_comment("#")
 
             option_is_manually_set = (
                 option.where_defined != ConfigOption.DEFAULT_DEFINITION
             )
 
             if option_is_manually_set:
-                append_comment("# The value below was set in %s" % option.where_defined)
+                append_comment("The value below was set in %s" % option.where_defined)
 
             toml_setting = toml.dumps({key: option.value})
 
@@ -139,11 +154,20 @@ def append_newline():
 
 
 def _clean(txt):
-    """Replace all whitespace with a single space."""
-    return " ".join(txt.split()).strip()
+    """Replace sequences of multiple spaces with a single space, excluding newlines.
+
+    Preserves leading and trailing spaces, and does not modify spaces in between lines.
+    """
+    return re.sub(" +", " ", txt)
 
 
 def _clean_paragraphs(txt):
+    """Split the text into paragraphs, preserve newlines within the paragraphs."""
+    # Strip both leading and trailing newlines.
+    txt = txt.strip("\n")
     paragraphs = txt.split("\n\n")
-    cleaned_paragraphs = [_clean(x) for x in paragraphs]
+    cleaned_paragraphs = [
+        "\n".join(_clean(line) for line in paragraph.split("\n"))
+        for paragraph in paragraphs
+    ]
     return cleaned_paragraphs


Test patch:
diff --git a/lib/tests/streamlit/config_util_test.py b/lib/tests/streamlit/config_util_test.py
--- a/lib/tests/streamlit/config_util_test.py
+++ b/lib/tests/streamlit/config_util_test.py
@@ -22,6 +22,7 @@
 from parameterized import parameterized
 
 from streamlit import config, config_util
+from streamlit.config_option import ConfigOption
 
 CONFIG_OPTIONS_TEMPLATE = config._config_options_template
 CONFIG_SECTION_DESCRIPTIONS = copy.deepcopy(config._section_descriptions)
@@ -37,7 +38,11 @@ def create_config_options(overrides):
 class ConfigUtilTest(unittest.TestCase):
     def test_clean(self):
         result = config_util._clean(" clean    this         text  ")
-        self.assertEqual("clean this text", result)
+        self.assertEqual(" clean this text ", result)
+
+    def test_clean_empty_string(self):
+        result = config_util._clean("")
+        self.assertEqual("", result)
 
     def test_clean_paragraphs(self):
         # from https://www.lipsum.com/
@@ -54,14 +59,18 @@ def test_clean_paragraphs(self):
         )
 
         truth = [
-            "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
-            "Curabitur ac fermentum eros.",
-            "Maecenas libero est, ultricies eget ligula eget,",
+            "Lorem ipsum dolor sit amet,\nconsectetur adipiscing elit.",
+            " Curabitur ac fermentum eros.",
+            "Maecenas libero est,\n ultricies\neget ligula eget, ",
         ]
 
         result = config_util._clean_paragraphs(input)
         self.assertEqual(truth, result)
 
+    def test_clean_paragraphs_empty_string(self):
+        result = config_util._clean_paragraphs("")
+        self.assertEqual([""], result)
+
     @patch("streamlit.config_util.click.echo")
     def test_default_config_options_commented_out(self, patched_echo):
         config_options = create_config_options(
@@ -146,3 +155,121 @@ def test_server_option_changed(self, old, new, changed):
         self.assertEqual(
             config_util.server_option_changed(old_options, new_options), changed
         )
+
+    @patch("streamlit.config_util.click.echo")
+    def test_newlines_preserved_in_description(self, patched_echo):
+        config_options = {
+            "server.customOption": ConfigOption(
+                key="server.customOption",
+                description="""
+                    This option has multiple lines.
+                    Each line should be preserved.
+                    Even this one.
+                """,
+                default_val="default",
+                type_=str,
+            )
+        }
+
+        config_util.show_config(CONFIG_SECTION_DESCRIPTIONS, config_options)
+
+        [(args, _)] = patched_echo.call_args_list
+        # Remove the ascii escape sequences used to color terminal output.
+        output = re.compile(r"\x1b[^m]*m").sub("", args[0])
+        lines = set(output.split("\n"))
+
+        assert "# This option has multiple lines." in lines
+        assert "# Each line should be preserved." in lines
+        assert "# Even this one." in lines
+
+    @patch("streamlit.config_util.click.echo")
+    def test_omits_empty_lines_at_description_start(self, patched_echo):
+        config_options = {
+            "server.customOption": ConfigOption(
+                key="server.customOption",
+                description="""
+
+                    This option's description starts from third line.
+                    All preceding empty lines should be removed.
+                """,
+                default_val="default",
+                type_=str,
+            )
+        }
+
+        config_util.show_config(CONFIG_SECTION_DESCRIPTIONS, config_options)
+
+        [(args, _)] = patched_echo.call_args_list
+        # Remove the ascii escape sequences used to color terminal output.
+        output = re.compile(r"\x1b[^m]*m").sub("", args[0])
+        lines = output.split("\n")
+        description_index = lines.index(
+            "# This option's description starts from third line."
+        )
+
+        assert (
+            description_index > 1
+        ), "Description should not be at the start of the output"
+        assert (
+            lines[description_index - 1].strip() == ""
+        ), "Preceding line should be empty (this line separates config options)"
+        assert (
+            lines[description_index - 2].strip() != ""
+        ), "The line before the preceding line should not be empty (this is the section header)"
+
+    @patch("streamlit.config_util.click.echo")
+    def test_description_appears_before_option(self, patched_echo):
+        config_options = {
+            "server.customOption": ConfigOption(
+                key="server.customOption",
+                description="This option's description should appear before the option.",
+                default_val="default",
+                type_=str,
+            )
+        }
+
+        config_util.show_config(CONFIG_SECTION_DESCRIPTIONS, config_options)
+
+        [(args, _)] = patched_echo.call_args_list
+        # Remove the ascii escape sequences used to color terminal output.
+        output = re.compile(r"\x1b[^m]*m").sub("", args[0])
+        lines = output.split("\n")
+
+        # Find the index of the description and the option in the output.
+        description_index = lines.index(
+            "# This option's description should appear before the option."
+        )
+        option_index = lines.index('# customOption = "default"')
+
+        # Assert that the description appears before the option.
+        self.assertLess(description_index, option_index)
+
+    @patch("streamlit.config_util.click.echo")
+    def test_show_config_section_formatting(self, patched_echo):
+        config_options = create_config_options({"server.address": "localhost"})
+        config_util.show_config(CONFIG_SECTION_DESCRIPTIONS, config_options)
+
+        [(args, _)] = patched_echo.call_args_list
+        output = re.compile(r"\x1b[^m]*m").sub("", args[0])
+        lines = output.split("\n")
+
+        self.assertIn("[server]", lines)
+
+    @patch("streamlit.config_util.click.echo")
+    def test_show_config_hidden_option(self, patched_echo):
+        config_options = {
+            "server.hiddenOption": ConfigOption(
+                key="server.hiddenOption",
+                description="This is a hidden option.",
+                default_val="default",
+                type_=str,
+                visibility="hidden",
+            )
+        }
+        config_util.show_config(CONFIG_SECTION_DESCRIPTIONS, config_options)
+
+        [(args, _)] = patched_echo.call_args_list
+        output = re.compile(r"\x1b[^m]*m").sub("", args[0])
+        lines = output.split("\n")
+
+        self.assertNotIn("# This is a hidden option.", lines)