Repository: Textualize/textual

Issue:
Update dependancies to support Python 3.12
With Python 3.12:
```console
pip install textual
```
Fails with:
```
ERROR: Could not find a version that satisfies the requirement tree_sitter_languages>=1.7.0; python_version >= "3.8" and python_version < "4.0" (from textual) (from versions: none)
ERROR: No matching distribution found for tree_sitter_languages>=1.7.0; python_version >= "3.8" and python_version < "4.0"
```

Discussion:

Patch:
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 882da2b35..02e367aa4 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -17,7 +17,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, windows-latest, macos-latest]
-        python-version: ["3.8", "3.9", "3.10", "3.11"]
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
     defaults:
       run:
         shell: bash
@@ -32,9 +32,18 @@ jobs:
           cache: 'poetry'
       - name: Install dependencies
         run: poetry install --no-interaction --extras syntax
+        if: ${{ matrix.python-version != '3.12' }}
+      - name: Install dependencies for 3.12  # https://github.com/Textualize/textual/issues/3491#issuecomment-1854156476
+        run: poetry install --no-interaction
+        if: ${{ matrix.python-version == '3.12' }}
       - name: Test with pytest
         run: |
           poetry run pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing
+        if: ${{ matrix.python-version != '3.12' }}
+      - name: Test with pytest for 3.12  # https://github.com/Textualize/textual/issues/3491#issuecomment-1854156476
+        run: |
+          poetry run pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing -m 'not syntax'
+        if: ${{ matrix.python-version == '3.12' }}
       - name: Upload snapshot report
         if: always()
         uses: actions/upload-artifact@v3
diff --git a/docs/getting_started.md b/docs/getting_started.md
index 0e031a266..46addc0bc 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -2,7 +2,7 @@ All you need to get started building Textual apps.
 
 ## Requirements
 
-Textual requires Python 3.7 or later (if you have a choice, pick the most recent Python). Textual runs on Linux, macOS, Windows and probably any OS where Python also runs.
+Textual requires Python 3.8 or later (if you have a choice, pick the most recent Python). Textual runs on Linux, macOS, Windows and probably any OS where Python also runs.
 
 !!! info inline end "Your platform"
 
diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md
index a8c648d64..bc3a5e25a 100644
--- a/docs/widgets/text_area.md
+++ b/docs/widgets/text_area.md
@@ -56,9 +56,6 @@ To update the parser used for syntax highlighting, set the [`language`][textual.
 text_area.language = "markdown"
 ```
 
-!!! note
-    Syntax highlighting is unavailable on Python 3.7.
-
 !!! note
     More built-in languages will be added in the future. For now, you can [add your own](#adding-support-for-custom-languages).
 
@@ -391,10 +388,6 @@ from tree_sitter_languages import get_language
 java_language = get_language("java")
 ```
 
-!!! note
-
-    `py-tree-sitter-languages` may not be available on some architectures (e.g. Macbooks with Apple Silicon running Python 3.7).
-
 The exact version of the parser used when you call `get_language` can be checked via
 the [`repos.txt` file](https://github.com/grantjenks/py-tree-sitter-languages/blob/a6d4f7c903bf647be1bdcfa504df967d13e40427/repos.txt) in
 the version of `py-tree-sitter-languages` you're using. This file contains links to the GitHub
diff --git a/examples/merlin.py b/examples/merlin.py
new file mode 100644
index 000000000..0a0287a4e
--- /dev/null
+++ b/examples/merlin.py
@@ -0,0 +1,173 @@
+from __future__ import annotations
+
+import random
+from datetime import timedelta
+from time import monotonic
+
+from textual import events
+from textual.app import App, ComposeResult
+from textual.containers import Grid
+from textual.reactive import var
+from textual.renderables.gradient import LinearGradient
+from textual.widget import Widget
+from textual.widgets import Digits, Label, Switch
+
+# A nice rainbow of colors.
+COLORS = [
+    "#881177",
+    "#aa3355",
+    "#cc6666",
+    "#ee9944",
+    "#eedd00",
+    "#99dd55",
+    "#44dd88",
+    "#22ccbb",
+    "#00bbcc",
+    "#0099cc",
+    "#3366bb",
+    "#663399",
+]
+
+
+# Maps a switch number on to other switch numbers, which should be toggled.
+TOGGLES: dict[int, tuple[int, ...]] = {
+    1: (2, 4, 5),
+    2: (1, 3),
+    3: (2, 5, 6),
+    4: (1, 7),
+    5: (2, 4, 6, 8),
+    6: (3, 9),
+    7: (4, 5, 8),
+    8: (7, 9),
+    9: (5, 6, 8),
+}
+
+
+class LabelSwitch(Widget):
+    """Switch with a numeric label."""
+
+    DEFAULT_CSS = """
+    LabelSwitch Label {
+        text-align: center;
+        width: 1fr;       
+        text-style: bold;         
+    }
+
+    LabelSwitch Label#label-5 {
+        color: $text-disabled;
+    }
+    """
+
+    def __init__(self, switch_no: int) -> None:
+        self.switch_no = switch_no
+        super().__init__()
+
+    def compose(self) -> ComposeResult:
+        """Compose the label and a switch."""
+        yield Label(str(self.switch_no), id=f"label-{self.switch_no}")
+        yield Switch(id=f"switch-{self.switch_no}", name=str(self.switch_no))
+
+
+class Timer(Digits):
+    """Displays a timer that stops when you win."""
+
+    DEFAULT_CSS = """
+    Timer {
+        text-align: center;
+        width: auto;
+        margin: 2 8;
+        color: $warning;
+    }
+    """
+    start_time = var(0.0)
+    running = var(True)
+
+    def on_mount(self) -> None:
+        """Start the timer on mount."""
+        self.start_time = monotonic()
+        self.set_interval(1, self.tick)
+        self.tick()
+
+    def tick(self) -> None:
+        """Called from `set_interval` to update the clock."""
+        if self.start_time == 0 or not self.running:
+            return
+        time_elapsed = timedelta(seconds=int(monotonic() - self.start_time))
+        self.update(str(time_elapsed))
+
+
+class MerlinApp(App):
+    """A simple reproduction of one game on the Merlin hand held console."""
+
+    CSS = """
+    Screen {
+        align: center middle;   
+    }
+
+    Screen.-win {
+        background: transparent;
+    }
+
+    Screen.-win Timer {
+        color: $success;
+    }    
+    
+    Grid {
+        width: auto;
+        height: auto;
+        border: thick $primary;
+        padding: 1 2;
+        grid-size: 3 3;
+        grid-rows: auto;
+        grid-columns: auto;
+        grid-gutter: 1 1;
+        background: $surface;
+    }
+    """
+
+    def render(self) -> LinearGradient:
+        """Renders a gradient, when the background is transparent."""
+        stops = [(i / (len(COLORS) - 1), c) for i, c in enumerate(COLORS)]
+        return LinearGradient(30.0, stops)
+
+    def compose(self) -> ComposeResult:
+        """Compose a timer, and a grid of 9 switches."""
+        yield Timer()
+        with Grid():
+            for switch in (7, 8, 9, 4, 5, 6, 1, 2, 3):
+                yield LabelSwitch(switch)
+
+    def on_mount(self) -> None:
+        """Randomize the switches on mount."""
+        for switch_no in range(1, 10):
+            if random.randint(0, 1):
+                self.query_one(f"#switch-{switch_no}", Switch).toggle()
+
+    def check_win(self) -> bool:
+        """Check for a win."""
+        on_switches = {
+            int(switch.name or "0") for switch in self.query(Switch) if switch.value
+        }
+        return on_switches == {1, 2, 3, 4, 6, 7, 8, 9}
+
+    def on_switch_changed(self, event: Switch.Changed) -> None:
+        """Called when a switch is toggled."""
+        # The switch that was pressed
+        switch_no = int(event.switch.name or "0")
+        # Also toggle corresponding switches
+        with self.prevent(Switch.Changed):
+            for toggle_no in TOGGLES[switch_no]:
+                self.query_one(f"#switch-{toggle_no}", Switch).toggle()
+        # Check the win
+        if self.check_win():
+            self.query_one("Screen").add_class("-win")
+            self.query_one(Timer).running = False
+
+    def on_key(self, event: events.Key) -> None:
+        """Maps switches to keys, so we can use the keyboard as well."""
+        if event.character and event.character.isdigit():
+            self.query_one(f"#switch-{event.character}", Switch).toggle()
+
+
+if __name__ == "__main__":
+    MerlinApp().run()
diff --git a/pyproject.toml b/pyproject.toml
index 581772683..a3469a5d1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -83,6 +83,7 @@ testpaths = ["tests"]
 addopts = "--strict-markers"
 markers = [
     "integration_test: marks tests as slow integration tests (deselect with '-m \"not integration_test\"')",
+    "syntax: marks tests that require syntax highlighting (deselect with '-m \"not syntax\"')",
 ]
 
 [build-system]


Test patch:
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index be8a4b4e8..66cbe6a53 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -795,6 +795,7 @@ def test_nested_fr(snap_compare) -> None:
     assert snap_compare(SNAPSHOT_APPS_DIR / "nested_fr.py")
 
 
+@pytest.mark.syntax
 @pytest.mark.parametrize("language", BUILTIN_LANGUAGES)
 def test_text_area_language_rendering(language, snap_compare):
     # This test will fail if we're missing a snapshot test for a valid
@@ -846,6 +847,7 @@ I am the final line."""
     )
 
 
+@pytest.mark.syntax
 @pytest.mark.parametrize(
     "theme_name", [theme.name for theme in TextAreaTheme.builtin_themes()]
 )
diff --git a/tests/text_area/test_languages.py b/tests/text_area/test_languages.py
index 6124da0cb..7e33fcf72 100644
--- a/tests/text_area/test_languages.py
+++ b/tests/text_area/test_languages.py
@@ -1,5 +1,3 @@
-import sys
-
 import pytest
 
 from textual.app import App, ComposeResult
@@ -61,7 +59,7 @@ async def test_setting_unknown_language():
             text_area.language = "this-language-doesnt-exist"
 
 
-@pytest.mark.skipif(sys.version_info < (3, 8), reason="tree-sitter requires python3.8 or higher")
+@pytest.mark.syntax
 async def test_register_language():
     app = TextAreaApp()
 
@@ -84,7 +82,7 @@ async def test_register_language():
         assert text_area.language == "elm"
 
 
-@pytest.mark.skipif(sys.version_info < (3, 8), reason="tree-sitter requires python3.8 or higher")
+@pytest.mark.syntax
 async def test_register_language_existing_language():
     app = TextAreaApp()
     async with app.run_test():