Repository: Textualize/textual
Pull request: Add a textual diagnose CLI command
This, in this early version anyway, emits a simple dump of GitHub-issue-friendly markdown text that lists all of the key information we may want to know when someone using having a problem with Textual.

See #1542.


Problem statement:
Add 'textual diagnose' CLI command
We need a textual diagnose command which writes out some basic diagnostic information. We can ask for this in issue templates.

- Textual version
- Python version
- Path to Python executable
- OS
- Detect terminal, env vars TERM, COLORTERM, other terminal vars
- Output of rich.console.Console.options

The output may be colorized, but should be in an easy cut n pastable format.

Hints from issue discussion:
Thank you for your issue. Give us a little time to review it.

PS. You might want to check the [FAQ](https://github.com/textualize/textual/blob/main/FAQ.md) if you haven't done so already.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)

Commit message: DRY getting an environment variable representation

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cd10be6fb..5a19bc726b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Added read-only public access to the children of a `TreeNode` via `TreeNode.children` https://github.com/Textualize/textual/issues/1398
 - Added `Tree.get_node_by_id` to allow getting a node by its ID https://github.com/Textualize/textual/pull/1535
 - Added a `Tree.NodeHighlighted` message, giving a `on_tree_node_highlighted` event handler https://github.com/Textualize/textual/issues/1400
+- Added `diagnose` as a `texttual` command https://github.com/Textualize/textual/issues/1542
 
 ### Changed
 
diff --git a/src/textual/cli/cli.py b/src/textual/cli/cli.py
index 9113338f18..6c99811d74 100644
--- a/src/textual/cli/cli.py
+++ b/src/textual/cli/cli.py
@@ -131,3 +131,11 @@ def keys():
     from textual.cli.previews import keys
 
     keys.app.run()
+
+
+@run.command("diagnose")
+def run_diagnose():
+    """Print information about the Textual environment"""
+    from textual.cli.tools.diagnose import diagnose
+
+    diagnose()
diff --git a/src/textual/cli/tools/__init__.py b/src/textual/cli/tools/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/textual/cli/tools/diagnose.py b/src/textual/cli/tools/diagnose.py
new file mode 100644
index 0000000000..5d39d36b57
--- /dev/null
+++ b/src/textual/cli/tools/diagnose.py
@@ -0,0 +1,133 @@
+"""Textual CLI command code to print diagnostic information."""
+
+import os
+import sys
+import platform
+from importlib_metadata import version
+from rich.console import Console
+
+
+def _section(title: str, values: dict[str, str]) -> None:
+    """Print a collection of named values within a titled section.
+
+    Args:
+        title (str): The title for the section.
+        values (dict[str, str]): The values to print out.
+    """
+    max_name = len(max(list(values.keys()), key=len))
+    max_value = len(max(list(values.values()), key=len))
+    print(f"## {title}")
+    print()
+    print(f"| {'Name':{max_name}} | {'Value':{max_value}} |")
+    print(f"|-{'-' * max_name}-|-{'-'*max_value}-|")
+    for name, value in values.items():
+        print(f"| {name:{max_name}} | {value:{max_value}} |")
+    print()
+
+
+def _versions() -> None:
+    """Print useful version numbers."""
+    _section("Versions", {"Textual": version("textual"), "Rich": version("rich")})
+
+
+def _python() -> None:
+    """Print information about Python."""
+    _section(
+        "Python",
+        {
+            "Version": platform.python_version(),
+            "Implementation": platform.python_implementation(),
+            "Compiler": platform.python_compiler(),
+            "Executable": sys.executable,
+        },
+    )
+
+
+def _os() -> None:
+    _section(
+        "Operating System",
+        {
+            "System": platform.system(),
+            "Release": platform.release(),
+            "Version": platform.version(),
+        },
+    )
+
+
+def _guess_term() -> str:
+    """Try and guess which terminal is being used."""
+
+    # First obvious place to look is in $TERM_PROGRAM.
+    term_program = os.environ.get("TERM_PROGRAM")
+
+    if term_program is None:
+        # Seems we couldn't get it that way. Let's check for some of the
+        # more common terminal signatures.
+        if "ALACRITTY_WINDOW_ID" in os.environ:
+            term_program = "Alacritty"
+        elif "KITTY_PID" in os.environ:
+            term_program = "Kitty"
+        elif "WT_SESSION" in os.environ:
+            term_program = "Windows Terminal"
+        elif "INSIDE_EMACS" in os.environ and os.environ["INSIDE_EMACS"]:
+            term_program = (
+                f"GNU Emacs {' '.join(os.environ['INSIDE_EMACS'].split(','))}"
+            )
+        elif "JEDITERM_SOURCE_ARGS" in os.environ:
+            term_program = "PyCharm"
+
+    else:
+        # See if we can pull out some sort of version information too.
+        term_version = os.environ.get("TERM_PROGRAM_VERSION")
+        if term_version is not None:
+            term_program = f"{term_program} ({term_version})"
+
+    return "*Unknown*" if term_program is None else term_program
+
+
+def _env(var_name: str) -> str:
+    """Get a representation of an environment variable.
+
+    Args:
+        var_name (str): The name of the variable to get.
+
+    Returns:
+        str: The value, or an indication that it isn't set.
+    """
+    return os.environ.get(var_name, "*Not set*")
+
+
+def _term() -> None:
+    """Print information about the terminal."""
+    _section(
+        "Terminal",
+        {
+            "Terminal Application": _guess_term(),
+            "TERM": _env("TERM"),
+            "COLORTERM": _env("COLORTERM"),
+            "FORCE_COLOR": _env("FORCE_COLOR"),
+            "NO_COLOR": _env("NO_COLOR"),
+        },
+    )
+
+
+def _console() -> None:
+    """Print The Rich console options."""
+    _section(
+        "Rich Console options",
+        {k: str(v) for k, v in Console().options.__dict__.items()},
+    )
+
+
+def diagnose() -> None:
+    """Print information about Textual and its environment to help diagnose problems."""
+    print("# Textual Diagnostics")
+    print()
+    _versions()
+    _python()
+    _os()
+    _term()
+    _console()
+    # TODO: Recommended changes. Given all of the above, make any useful
+    # recommendations to the user (eg: don't use Windows console, use
+    # Windows Terminal; don't use macOS Terminal.app, etc).


Code review comments:
diff_hunk: @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Added read-only public access to the children of a `TreeNode` via `TreeNode.children` https://github.com/Textualize/textual/issues/1398
 - Added `Tree.get_node_by_id` to allow getting a node by its ID https://github.com/Textualize/textual/pull/1535
 - Added a `Tree.NodeHighlighted` message, giving a `on_tree_node_highlighted` event handler https://github.com/Textualize/textual/issues/1400
+- Added `diagnose` as a `texttual` command https://github.com/Textualize/textual/issues/1542
path: CHANGELOG.md
text: @user1:
```suggestion
- Added `diagnose` as a `textual` command https://github.com/Textualize/textual/issues/1542
```


@author:
Good catch!

diff_hunk: @@ -0,0 +1,133 @@
+"""Textual CLI command code to print diagnostic information."""
+
+import os
+import sys
+import platform
+from importlib_metadata import version
+from rich.console import Console
+
+
+def _section(title: str, values: dict[str, str]) -> None:
+    """Print a collection of named values within a titled section.
+
+    Args:
+        title (str): The title for the section.
+        values (dict[str, str]): The values to print out.
+    """
+    max_name = len(max(list(values.keys()), key=len))
path: src/textual/cli/tools/diagnose.py
text: @user1:
It's a nitpick, but something like

```suggestion
    max_name = max(map(len, values.keys()))
```

Looks a bit less redundant, no?
(Ditto for max_values.)

@author:
Actually, yeah, I think that's my preference too -- too much in the habit of staying away from `map` and friends as so much of the Python world seems to dislike "proper" functional programming. ;-)

@user1:
`max(len(k) for k in values.keys())` would also come to mind :D