Repository: Textualize/textual

Issue:
Opaque and misleading errors from poorly formatted DOM queries.
When performing a DOM query using `query_one` and other methods, and specifying an ID in a string, the following error occurs when the ID string does not include a #.

For a query like this:
```python
baud_rate = self.query_one("baud_rate_selection").value
```

We get the following error:

```
 Error in stylesheet:
 :1:1
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ❱ 1 baud_rate_selection                                                                                                           │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
 • Expected selector or end of file (found 'baud_rate_selection').
 • Did you forget a semicolon at the end of a line?
```

Recommendation:

Adjust the error handling for "stylesheets" that come from a DOM query to reflect common errors *with these queries* rather than more general stylesheet issues.

Note:

If you're passing by and need a solution for the above stylesheet error, just add a `#` to the beginning of the query string. If I corrected the above query string it would look like `"#baud_rate_selection"`. See https://textual.textualize.io/guide/queries/ for more details.

Discussion:
@github-actions: Thank you for your issue. Give us a little time to review it.

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

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)@github-actions: Don't forget to [star](https://github.com/Textualize/textual) the repository!

Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.

Solution patch:
diff --git a/src/textual/constants.py b/src/textual/constants.py
index a9d7db7192..f4d1673888 100644
--- a/src/textual/constants.py
+++ b/src/textual/constants.py
@@ -27,24 +27,53 @@ def _get_environ_bool(name: str) -> bool:
     return has_environ
 
 
-def _get_environ_int(name: str, default: int) -> int:
+def _get_environ_int(name: str, default: int, minimum: int | None = None) -> int:
     """Retrieves an integer environment variable.
 
     Args:
         name: Name of environment variable.
         default: The value to use if the value is not set, or set to something other
             than a valid integer.
+        minimum: Optional minimum value.
 
     Returns:
         The integer associated with the environment variable if it's set to a valid int
             or the default value otherwise.
     """
     try:
-        return int(os.environ[name])
+        value = int(os.environ[name])
     except KeyError:
         return default
     except ValueError:
         return default
+    if minimum is not None:
+        return max(minimum, value)
+    return value
+
+
+def _get_environ_port(name: str, default: int) -> int:
+    """Get a port no. from an environment variable.
+
+    Note that there is no 'minimum' here, as ports are more like names than a scalar value.
+
+    Args:
+        name: Name of environment variable.
+        default: The value to use if the value is not set, or set to something other
+            than a valid port.
+
+    Returns:
+        An integer port number.
+
+    """
+    try:
+        value = int(os.environ[name])
+    except KeyError:
+        return default
+    except ValueError:
+        return default
+    if value < 0 or value > 65535:
+        return default
+    return value
 
 
 def _is_valid_animation_level(value: str) -> TypeGuard[AnimationLevel]:
@@ -89,11 +118,11 @@ def _get_textual_animations() -> AnimationLevel:
 DEVTOOLS_HOST: Final[str] = get_environ("TEXTUAL_DEVTOOLS_HOST", "127.0.0.1")
 """The host where textual console is running."""
 
-DEVTOOLS_PORT: Final[int] = _get_environ_int("TEXTUAL_DEVTOOLS_PORT", 8081)
+DEVTOOLS_PORT: Final[int] = _get_environ_port("TEXTUAL_DEVTOOLS_PORT", 8081)
 """Constant with the port that the devtools will connect to."""
 
-SCREENSHOT_DELAY: Final[int] = _get_environ_int("TEXTUAL_SCREENSHOT", -1)
-"""Seconds delay before taking screenshot."""
+SCREENSHOT_DELAY: Final[int] = _get_environ_int("TEXTUAL_SCREENSHOT", -1, minimum=-1)
+"""Seconds delay before taking screenshot, -1 for no screenshot."""
 
 SCREENSHOT_LOCATION: Final[str | None] = get_environ("TEXTUAL_SCREENSHOT_LOCATION")
 """The location where screenshots should be written."""
@@ -107,7 +136,7 @@ def _get_textual_animations() -> AnimationLevel:
 SHOW_RETURN: Final[bool] = _get_environ_bool("TEXTUAL_SHOW_RETURN")
 """Write the return value on exit."""
 
-MAX_FPS: Final[int] = _get_environ_int("TEXTUAL_FPS", 60)
+MAX_FPS: Final[int] = _get_environ_int("TEXTUAL_FPS", 60, minimum=1)
 """Maximum frames per second for updates."""
 
 COLOR_SYSTEM: Final[str | None] = get_environ("TEXTUAL_COLOR_SYSTEM", "auto")
@@ -116,10 +145,10 @@ def _get_textual_animations() -> AnimationLevel:
 TEXTUAL_ANIMATIONS: Final[AnimationLevel] = _get_textual_animations()
 """Determines whether animations run or not."""
 
-ESCAPE_DELAY: Final[float] = _get_environ_int("ESCDELAY", 100) / 1000.0
+ESCAPE_DELAY: Final[float] = _get_environ_int("ESCDELAY", 100, minimum=1) / 1000.0
 """The delay (in seconds) before reporting an escape key (not used if the extend key protocol is available)."""
 
-SLOW_THRESHOLD: int = _get_environ_int("TEXTUAL_SLOW_THRESHOLD", 500)
+SLOW_THRESHOLD: int = _get_environ_int("TEXTUAL_SLOW_THRESHOLD", 500, minimum=100)
 """The time threshold (in milliseconds) after which a warning is logged 
 if message processing exceeds this duration.
 """
diff --git a/src/textual/demo/widgets.py b/src/textual/demo/widgets.py
index 7b37a87e32..b104156b2d 100644
--- a/src/textual/demo/widgets.py
+++ b/src/textual/demo/widgets.py
@@ -8,6 +8,7 @@
 
 from textual import containers
 from textual.app import ComposeResult
+from textual.binding import Binding
 from textual.demo.data import COUNTRIES
 from textual.demo.page import PageScreen
 from textual.reactive import reactive, var
@@ -446,7 +447,7 @@ class WidgetsScreen(PageScreen):
     }
     """
 
-    BINDINGS = [("escape", "unfocus", "Unfocus any focused widget")]
+    BINDINGS = [Binding("escape", "unfocus", "Unfocus any focused widget", show=False)]
 
     def compose(self) -> ComposeResult:
         with containers.VerticalScroll() as container:
diff --git a/src/textual/dom.py b/src/textual/dom.py
index aa2fdb3983..4ccf94194b 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -40,9 +40,10 @@
 from textual.css.errors import DeclarationError, StyleValueError
 from textual.css.match import match
 from textual.css.parse import parse_declarations, parse_selectors
-from textual.css.query import NoMatches, TooManyMatches
+from textual.css.query import InvalidQueryFormat, NoMatches, TooManyMatches
 from textual.css.styles import RenderStyles, Styles
 from textual.css.tokenize import IDENTIFIER
+from textual.css.tokenizer import TokenError
 from textual.message_pump import MessagePump
 from textual.reactive import Reactive, ReactiveError, _Mutated, _watch
 from textual.timer import Timer
@@ -1441,7 +1442,12 @@ def query_one(
         else:
             query_selector = selector.__name__
 
-        selector_set = parse_selectors(query_selector)
+        try:
+            selector_set = parse_selectors(query_selector)
+        except TokenError:
+            raise InvalidQueryFormat(
+                f"Unable to parse {query_selector!r} as a query; check for syntax errors"
+            ) from None
 
         if all(selectors.is_simple for selectors in selector_set):
             cache_key = (self._nodes._updates, query_selector, expect_type)
@@ -1505,7 +1511,12 @@ def query_exactly_one(
         else:
             query_selector = selector.__name__
 
-        selector_set = parse_selectors(query_selector)
+        try:
+            selector_set = parse_selectors(query_selector)
+        except TokenError:
+            raise InvalidQueryFormat(
+                f"Unable to parse {query_selector!r} as a query; check for syntax errors"
+            ) from None
 
         if all(selectors.is_simple for selectors in selector_set):
             cache_key = (self._nodes._updates, query_selector, expect_type)


Test patch:
diff --git a/tests/test_constants.py b/tests/test_constants.py
new file mode 100644
index 0000000000..9eb30bc02a
--- /dev/null
+++ b/tests/test_constants.py
@@ -0,0 +1,31 @@
+from textual.constants import _get_environ_bool, _get_environ_int, _get_environ_port
+
+
+def test_environ_int(monkeypatch):
+    """Check minimum is applied."""
+    monkeypatch.setenv("FOO", "-1")
+    assert _get_environ_int("FOO", 1, minimum=0) == 0
+    monkeypatch.setenv("FOO", "0")
+    assert _get_environ_int("FOO", 1, minimum=0) == 0
+    monkeypatch.setenv("FOO", "1")
+    assert _get_environ_int("FOO", 1, minimum=0) == 1
+
+
+def test_environ_bool(monkeypatch):
+    """Anything other than "1" is False."""
+    monkeypatch.setenv("BOOL", "1")
+    assert _get_environ_bool("BOOL") is True
+    monkeypatch.setenv("BOOL", "")
+    assert _get_environ_bool("BOOL") is False
+    monkeypatch.setenv("BOOL", "0")
+    assert _get_environ_bool("BOOL") is False
+
+
+def test_environ_port(monkeypatch):
+    """Valid ports are between 0 and 65536."""
+    monkeypatch.setenv("PORT", "-1")
+    assert _get_environ_port("PORT", 80) == 80
+    monkeypatch.setenv("PORT", "0")
+    assert _get_environ_port("PORT", 80) == 0
+    monkeypatch.setenv("PORT", "65536")
+    assert _get_environ_port("PORT", 80) == 80
diff --git a/tests/test_query.py b/tests/test_query.py
index 4030003866..1199f7d5f0 100644
--- a/tests/test_query.py
+++ b/tests/test_query.py
@@ -14,6 +14,22 @@
 from textual.widgets import Input, Label
 
 
+def test_query_errors():
+    app = App()
+
+    with pytest.raises(InvalidQueryFormat):
+        app.query_one("foo_bar")
+
+    with pytest.raises(InvalidQueryFormat):
+        app.query("foo_bar")
+
+    with pytest.raises(InvalidQueryFormat):
+        app.query("1")
+
+    with pytest.raises(InvalidQueryFormat):
+        app.query_one("1")
+
+
 def test_query():
     class View(Widget):
         pass