Repository: Textualize/textual
Pull request: Add --port option to textual console.
**Please review the following checklist.**

- [x] Docstrings on all new or modified functions / classes 
- [x] Updated documentation
- [x] Updated CHANGELOG.md (where appropriate)

This will close #2014 

Problem statement:
Allow command-line option to change development port
I couldn't use `textual run --dev` because the server I was on had AV software listening on port 8081.

Luckily a quick `git grep` was able to fix it, but it would be nice to have that as a command-line option.

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)
Thanks for your suggestion! As soon as #2258 gets merged, you can use the option `--port` with the command `textual console` to specify the port to use.

Commit message: Mark unpredictable test as xfail.

This test gets an xfail mark until #2254 is open.

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6dbacc28a0..03fe0b1ac6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,13 +16,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 
 - Added `DataTable.remove_row` method https://github.com/Textualize/textual/pull/2253
 - `Widget.scroll_to_center` now scrolls the widget to the center of the screen https://github.com/Textualize/textual/pull/2255
+- option `--port` to the command `textual console` to specify which port the console should connect to https://github.com/Textualize/textual/pull/2258
 - Added `TabActivated` message to `TabbedContent` https://github.com/Textualize/textual/pull/2260
 
-
 ### Fixed
 
 - Fixed order styles are applied in DataTable - allows combining of renderable styles and component classes https://github.com/Textualize/textual/pull/2272
 
+
 ## [0.19.1] - 2023-04-10
 
 ### Fixed
diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md
index dd0f168b8e..4fbffe6cbf 100644
--- a/docs/guide/devtools.md
+++ b/docs/guide/devtools.md
@@ -90,6 +90,20 @@ Multiple groups may be excluded, for example to exclude everything except warnin
 textual console -x SYSTEM -x EVENT -x DEBUG -x INFO
 ```
 
+### Custom port
+
+You can use the option `--port` to specify a custom port to run the console on, which comes in handy if you have other software running on the port that Textual uses by default:
+
+```bash
+textual console --port 7342
+```
+
+Then, use the command `run` with the same `--port` option:
+
+```bash
+textual run --dev --port 7342 my_app.py
+```
+
 
 ## Textual log
 
diff --git a/src/textual/cli/cli.py b/src/textual/cli/cli.py
index c4d1da0a19..c1186f99d7 100644
--- a/src/textual/cli/cli.py
+++ b/src/textual/cli/cli.py
@@ -2,6 +2,8 @@
 
 import sys
 
+from ..constants import DEFAULT_DEVTOOLS_PORT, DEVTOOLS_PORT_ENVIRON_VARIABLE
+
 try:
     import click
 except ImportError:
@@ -21,14 +23,27 @@ def run():
 
 
 @run.command(help="Run the Textual Devtools console.")
+@click.option(
+    "--port",
+    "port",
+    type=int,
+    default=None,
+    metavar="PORT",
+    help=f"Port to use for the development mode console. Defaults to {DEFAULT_DEVTOOLS_PORT}.",
+)
 @click.option("-v", "verbose", help="Enable verbose logs.", is_flag=True)
 @click.option("-x", "--exclude", "exclude", help="Exclude log group(s)", multiple=True)
-def console(verbose: bool, exclude: list[str]) -> None:
+def console(port: int | None, verbose: bool, exclude: list[str]) -> None:
     """Launch the textual console."""
+    import os
+
     from rich.console import Console
 
     from textual.devtools.server import _run_devtools
 
+    if port is not None:
+        os.environ[DEVTOOLS_PORT_ENVIRON_VARIABLE] = str(port)
+
     console = Console()
     console.clear()
     console.show_cursor(False)
@@ -78,6 +93,14 @@ def _post_run_warnings() -> None:
 )
 @click.argument("import_name", metavar="FILE or FILE:APP")
 @click.option("--dev", "dev", help="Enable development mode", is_flag=True)
+@click.option(
+    "--port",
+    "port",
+    type=int,
+    default=None,
+    metavar="PORT",
+    help=f"Port to use for the development mode console. Defaults to {DEFAULT_DEVTOOLS_PORT}.",
+)
 @click.option("--press", "press", help="Comma separated keys to simulate press")
 @click.option(
     "--screenshot",
@@ -86,7 +109,9 @@ def _post_run_warnings() -> None:
     metavar="DELAY",
     help="Take screenshot after DELAY seconds",
 )
-def run_app(import_name: str, dev: bool, press: str, screenshot: int | None) -> None:
+def run_app(
+    import_name: str, dev: bool, port: int | None, press: str, screenshot: int | None
+) -> None:
     """Run a Textual app.
 
     The code to run may be given as a path (ending with .py) or as a Python
@@ -107,7 +132,6 @@ def run_app(import_name: str, dev: bool, press: str, screenshot: int | None) ->
     in quotes:
 
         textual run "foo.py arg --option"
-
     """
 
     import os
@@ -116,6 +140,9 @@ def run_app(import_name: str, dev: bool, press: str, screenshot: int | None) ->
 
     from textual.features import parse_features
 
+    if port is not None:
+        os.environ[DEVTOOLS_PORT_ENVIRON_VARIABLE] = str(port)
+
     features = set(parse_features(os.environ.get("TEXTUAL", "")))
     if dev:
         features.add("debug")
diff --git a/src/textual/constants.py b/src/textual/constants.py
index f3762b33d6..191a282162 100644
--- a/src/textual/constants.py
+++ b/src/textual/constants.py
@@ -15,6 +15,9 @@
 
 get_environ = os.environ.get
 
+DEVTOOLS_PORT_ENVIRON_VARIABLE = "TEXTUAL_CONSOLE_PORT"
+DEFAULT_DEVTOOLS_PORT = 8081
+
 
 def get_environ_bool(name: str) -> bool:
     """Check an environment variable switch.
@@ -25,10 +28,18 @@ def get_environ_bool(name: str) -> bool:
     Returns:
         `True` if the env var is "1", otherwise `False`.
     """
-    has_environ = os.environ.get(name) == "1"
+    has_environ = get_environ(name) == "1"
     return has_environ
 
 
+def get_port_for_devtools() -> int:
+    """Get the port to run the devtools on from the environment or the default."""
+    try:
+        return int(os.environ[DEVTOOLS_PORT_ENVIRON_VARIABLE])
+    except (KeyError, ValueError):
+        return DEFAULT_DEVTOOLS_PORT
+
+
 BORDERS = list(BORDER_CHARS)
 
 DEBUG: Final[bool] = get_environ_bool("TEXTUAL_DEBUG")
diff --git a/src/textual/devtools/client.py b/src/textual/devtools/client.py
index 3c8592ae61..83126e5cd7 100644
--- a/src/textual/devtools/client.py
+++ b/src/textual/devtools/client.py
@@ -16,8 +16,8 @@
 from rich.segment import Segment
 
 from .._log import LogGroup, LogVerbosity
+from ..constants import get_port_for_devtools
 
-DEVTOOLS_PORT = 8081
 WEBSOCKET_CONNECT_TIMEOUT = 3
 LOG_QUEUE_MAXSIZE = 512
 
@@ -88,10 +88,13 @@ class DevtoolsClient:
 
     Args:
         host: The host the devtools server is running on, defaults to "127.0.0.1"
-        port: The port the devtools server is accessed via, defaults to 8081
+        port: The port the devtools server is accessed via,
+            retrieved by `get_port_for_devtools` by default.
     """
 
-    def __init__(self, host: str = "127.0.0.1", port: int = DEVTOOLS_PORT) -> None:
+    def __init__(self, host: str = "127.0.0.1", port: int | None = None) -> None:
+        if port is None:
+            port = get_port_for_devtools()
         self.url: str = f"ws://{host}:{port}"
         self.session: aiohttp.ClientSession | None = None
         self.log_queue_task: Task | None = None
diff --git a/src/textual/devtools/server.py b/src/textual/devtools/server.py
index 4228ca6f53..daa2fcac4f 100644
--- a/src/textual/devtools/server.py
+++ b/src/textual/devtools/server.py
@@ -8,7 +8,7 @@
 from aiohttp.web_routedef import get
 from aiohttp.web_ws import WebSocketResponse
 
-from textual.devtools.client import DEVTOOLS_PORT
+from textual.devtools.client import get_port_for_devtools
 from textual.devtools.service import DevtoolsService
 
 DEFAULT_SIZE_CHANGE_POLL_DELAY_SECONDS = 2
@@ -46,7 +46,10 @@ def noop_print(_: str) -> None:
 
     try:
         run_app(
-            app, port=DEVTOOLS_PORT, print=noop_print, loop=asyncio.get_event_loop()
+            app,
+            port=get_port_for_devtools(),
+            print=noop_print,
+            loop=asyncio.get_event_loop(),
         )
     except OSError:
         from rich import print
diff --git a/tests/devtools/test_devtools_client.py b/tests/devtools/test_devtools_client.py
index 88088bff06..b67f558ba4 100644
--- a/tests/devtools/test_devtools_client.py
+++ b/tests/devtools/test_devtools_client.py
@@ -10,6 +10,7 @@
 from rich.panel import Panel
 
 from tests.utilities.render import wait_for_predicate
+from textual.constants import DEFAULT_DEVTOOLS_PORT
 from textual.devtools.client import DevtoolsClient
 from textual.devtools.redirect_output import DevtoolsLog
 
@@ -21,7 +22,7 @@
 
 def test_devtools_client_initialize_defaults():
     devtools = DevtoolsClient()
-    assert devtools.url == "ws://127.0.0.1:8081"
+    assert devtools.url == f"ws://127.0.0.1:{DEFAULT_DEVTOOLS_PORT}"
 
 
 async def test_devtools_client_is_connected(devtools):
diff --git a/tests/snapshot_tests/snapshot_apps/scroll_to_center.py b/tests/snapshot_tests/snapshot_apps/scroll_to_center.py
index fdd616cb6d..ffb692465e 100644
--- a/tests/snapshot_tests/snapshot_apps/scroll_to_center.py
+++ b/tests/snapshot_tests/snapshot_apps/scroll_to_center.py
@@ -18,7 +18,7 @@ class MyApp(App[None]):
 
     def compose(self) -> ComposeResult:
         with VerticalScroll():
-            yield Label(("SPAM\n" * 25)[:-1])
+            yield Label(("SPAM\n" * 205)[:-1])
             with VerticalScroll():
                 yield Label(("SPAM\n" * 53)[:-1])
                 with VerticalScroll(id="vertical"):
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index 35a0cebc2c..e8bde50c72 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -418,6 +418,9 @@ def test_scroll_visible(snap_compare):
     assert snap_compare(SNAPSHOT_APPS_DIR / "scroll_visible.py", press=["t"])
 
 
+@pytest.mark.xfail(
+    reason="Unpredictable while https://github.com/Textualize/textual/issues/2254 is open."
+)
 def test_scroll_to_center(snap_compare):
     # READ THIS IF THIS TEST FAILS:
     # While https://github.com/Textualize/textual/issues/2254 is open, the snapshot


Code review comments:
diff_hunk: @@ -25,10 +28,18 @@ def get_environ_bool(name: str) -> bool:
     Returns:
         `True` if the env var is "1", otherwise `False`.
     """
-    has_environ = os.environ.get(name) == "1"
+    has_environ = get_environ(name) == "1"
     return has_environ
 
 
+def get_port_for_devtools() -> int:
path: src/textual/constants.py
text: @user1:
This doesn't need to be a callable, as it is never going to change.

Can you create a constant, in the same way as the other symbols in this module.

@author:
I thought a callable made sense because we had to check if we'd use the default, or not.
Let me try again.