Repository: Textualize/textual

Issue:
get_option in SelectionList not working
The function `get_option` in `SelectionList` does not work as intended. The following raises `OptionDoesNotExist`

```
from textual.app import App
from textual.widgets import SelectionList
from textual.widgets.selection_list import Selection
class QuickApp(App):

    def compose(self):
        yield SelectionList(
            Selection("a", 1, id="a")
        )

    def on_mount(self):
        list = self.query_one(SelectionList)
        list.get_option("a")
```

I looked through the code and it looks like `_option_ids` is never set in `OptionList` but gets evaluated in `get_option_index`.



Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 288a4c420..d3f838be7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Parameter `animate` from `DataTable.move_cursor` was being ignored https://github.com/Textualize/textual/issues/3840
 - Fixed a crash if `DirectoryTree.show_root` was set before the DOM was fully available https://github.com/Textualize/textual/issues/2363
 - Live reloading of TCSS wouldn't apply CSS changes to screens under the top screen of the stack https://github.com/Textualize/textual/issues/3931
+- `SelectionList` option IDs are usable as soon as the widget is instantiated https://github.com/Textualize/textual/issues/3903
 - Fix issue with `Strip.crop` when crop window start aligned with strip end https://github.com/Textualize/textual/pull/3998
 - Fixed Strip.crop_extend https://github.com/Textualize/textual/pull/4011
 
diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py
index bed8c1e01..59e2adf92 100644
--- a/src/textual/widgets/_option_list.py
+++ b/src/textual/widgets/_option_list.py
@@ -351,7 +351,9 @@ class OptionList(ScrollView, can_focus=True):
         content that isn't an option.
         """
 
-        self._option_ids: dict[str, int] = {}
+        self._option_ids: dict[str, int] = {
+            option.id: index for index, option in enumerate(self._options) if option.id
+        }
         """A dictionary of option IDs and the option indexes they relate to."""
 
         self._lines: list[Line] = []
@@ -459,10 +461,6 @@ class OptionList(ScrollView, can_focus=True):
         """Clear down the content tracking information."""
         self._lines.clear()
         self._spans.clear()
-        # TODO: Having the option ID tracking be tied up with the main
-        # content tracking isn't necessary. Can possibly improve this a wee
-        # bit.
-        self._option_ids.clear()
 
     def _left_gutter_width(self) -> int:
         """Returns the size of any left gutter that should be taken into account.
@@ -501,7 +499,6 @@ class OptionList(ScrollView, can_focus=True):
         # Set up for doing less property access work inside the loop.
         lines_from = self.app.console.render_lines
         add_span = self._spans.append
-        option_ids = self._option_ids
         add_lines = self._lines.extend
 
         # Adjust the options for our purposes.
@@ -519,7 +516,7 @@ class OptionList(ScrollView, can_focus=True):
         # break out the individual lines that will be used to draw it, and
         # also set up the tracking of the actual options.
         line = 0
-        option = 0
+        option_index = 0
         padding = self.get_component_styles("option-list--option").padding
         for content in self._contents:
             if isinstance(content, Option):
@@ -527,8 +524,10 @@ class OptionList(ScrollView, can_focus=True):
                 # work out the lines needed to show it.
                 new_lines = [
                     Line(
-                        Strip(prompt_line).apply_style(Style(meta={"option": option})),
-                        option,
+                        Strip(prompt_line).apply_style(
+                            Style(meta={"option": option_index})
+                        ),
+                        option_index,
                     )
                     for prompt_line in lines_from(
                         Padding(content.prompt, padding) if padding else content.prompt,
@@ -537,11 +536,7 @@ class OptionList(ScrollView, can_focus=True):
                 ]
                 # Record the span information for the option.
                 add_span(OptionLineSpan(line, len(new_lines)))
-                if content.id is not None:
-                    # The option has an ID set, create a mapping from that
-                    # ID to the option so we can use it later.
-                    option_ids[content.id] = option
-                option += 1
+                option_index += 1
             else:
                 # The content isn't an option, so it must be a separator (if
                 # there were to be other non-option content for an option
@@ -602,9 +597,16 @@ class OptionList(ScrollView, can_focus=True):
             content = [self._make_content(item) for item in items]
             self._duplicate_id_check(content)
             self._contents.extend(content)
-            # Pull out the content that is genuine options and add them to the
-            # list of options.
-            self._options.extend([item for item in content if isinstance(item, Option)])
+            # Pull out the content that is genuine options. Add them to the
+            # list of options and map option IDs to their new indices.
+            new_options = [item for item in content if isinstance(item, Option)]
+            self._options.extend(new_options)
+            for new_option_index, new_option in enumerate(
+                new_options, start=len(self._options)
+            ):
+                if new_option.id:
+                    self._option_ids[new_option.id] = new_option_index
+
             self._refresh_content_tracking(force=True)
             self.refresh()
         return self
@@ -635,6 +637,12 @@ class OptionList(ScrollView, can_focus=True):
         option = self._options[index]
         del self._options[index]
         del self._contents[self._contents.index(option)]
+        # Decrement index of options after the one we just removed.
+        self._option_ids = {
+            option_id: option_index - 1 if option_index > index else option_index
+            for option_id, option_index in self._option_ids.items()
+            if option_index != index
+        }
         self._refresh_content_tracking(force=True)
         # Force a re-validation of the highlight.
         self.highlighted = self.highlighted
@@ -732,6 +740,7 @@ class OptionList(ScrollView, can_focus=True):
         """
         self._contents.clear()
         self._options.clear()
+        self._option_ids.clear()
         self.highlighted = None
         self._mouse_hovering_over = None
         self.virtual_size = Size(self.scrollable_content_region.width, 0)
@@ -966,7 +975,6 @@ class OptionList(ScrollView, can_focus=True):
 
         Args:
             top: Scroll highlight to top of the list.
-
         """
         highlighted = self.highlighted
         if highlighted is None:
@@ -1070,11 +1078,11 @@ class OptionList(ScrollView, can_focus=True):
                 # Looks like we've figured out the next option to jump to.
                 self.highlighted = target_option
 
-    def action_page_up(self):
+    def action_page_up(self) -> None:
         """Move the highlight up one page."""
         self._page(-1)
 
-    def action_page_down(self):
+    def action_page_down(self) -> None:
         """Move the highlight down one page."""
         self._page(1)
 


Test patch:
diff --git a/tests/css/test_css_reloading.py b/tests/css/test_css_reloading.py
index 4d1163aa1..e69060415 100644
--- a/tests/css/test_css_reloading.py
+++ b/tests/css/test_css_reloading.py
@@ -60,6 +60,7 @@ Label {
 
         # Clear the CSS from the file.
         Path(CSS_PATH).write_text("/* This file has no rules intentionally. */\n")
+        await pilot.pause()
         await pilot.app._on_css_change()
         # Height should fall back to 1.
         assert first_label.styles.height is not None
diff --git a/tests/option_list/test_option_list_create.py b/tests/option_list/test_option_list_create.py
index 3310579b8..69ccce06d 100644
--- a/tests/option_list/test_option_list_create.py
+++ b/tests/option_list/test_option_list_create.py
@@ -153,3 +153,11 @@ async def test_adding_multiple_duplicates_at_once() -> None:
                 ]
             )
         assert option_list.option_count == 5
+
+
+async def test_options_are_available_soon() -> None:
+    """Regression test for https://github.com/Textualize/textual/issues/3903."""
+
+    option = Option("", id="some_id")
+    option_list = OptionList(option)
+    assert option_list.get_option("some_id") is option
diff --git a/tests/selection_list/test_selection_list_create.py b/tests/selection_list/test_selection_list_create.py
index 09b1a2670..e189a3c9d 100644
--- a/tests/selection_list/test_selection_list_create.py
+++ b/tests/selection_list/test_selection_list_create.py
@@ -10,6 +10,7 @@ from __future__ import annotations
 
 import pytest
 from rich.text import Text
+
 from textual.app import App, ComposeResult
 from textual.widgets import SelectionList
 from textual.widgets.option_list import Option
@@ -98,9 +99,18 @@ async def test_add_non_selections() -> None:
         with pytest.raises(SelectionError):
             selections.add_option(("Nope", 0, False, 23))
 
+
 async def test_clear_options() -> None:
     """Clearing the options should also clear the selections."""
     async with SelectionListApp().run_test() as pilot:
         selections = pilot.app.query_one(SelectionList)
         selections.clear_options()
         assert selections.selected == []
+
+
+async def test_options_are_available_soon() -> None:
+    """Regression test for https://github.com/Textualize/textual/issues/3903."""
+
+    selection = Selection("", 0, id="some_id")
+    selection_list = SelectionList[int](selection)
+    assert selection_list.get_option("some_id") is selection