Repository: Textualize/textual
Pull request: Make `COMPONENT_CLASSES` inheritable and add classvar to prevent inheritance.
**Please review the following checklist.**

- [x] Docstrings on all new or modified functions / classes 
- ~[ ] Updated documentation~ Currently there's no documentation on `COMPONENT_CLASSES` and my suggestion would be to add it with #1532 
- [x] Updated CHANGELOG.md (where appropriate)

This fixes #1399 

Problem statement:
Make `COMPONENT_CLASSES` inheritable
Much like `DEFAULT_CSS` and similar things, look at the idea of making `COMPONENT_CLASSES` inheritable. At the moment, if you inherit from a widget that has a `COMPONENT_CLASSES`, and you set your own in your derived widget, you'll erase those of the parent. Right now the best option is to combine the sets. For example:

```python
class ThreeDimensionalTree(Tree[Anaglyph]):
    """Tree, but more so."""

    COMPONENT_CLASSES: ClassVar[set[str]] = Tree.COMPONENT_CLASSES | {
        "anaglyph--behind-screen",
        "anaglyph--in-front-of-screen",
    }
```

This will likely also need the addition of a `inherit_component_classes` to control if the inheriting should happen or not.

Hints from issue discussion:
We found the following entry in the [FAQ](https://github.com/textualize/textual/blob/main/FAQ.md) which you may find helpful:


- [Does Textual support images?](https://github.com/textualize/textual/blob/main/FAQ.md#does-textual-support-images)

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

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

Commit message: Merge branch 'main' into fix-1399

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index af9c300121..1d1c1209ab 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 a `_inherit_component_classes` class variable to control whether or not component classes are inherited from base classes https://github.com/Textualize/textual/issues/1399
 - Added `diagnose` as a `textual` command https://github.com/Textualize/textual/issues/1542
 
 ### Changed
@@ -22,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Fail-fast and print pretty tracebacks for Widget compose errors https://github.com/Textualize/textual/pull/1505
 - Added Widget._refresh_scroll to avoid expensive layout when scrolling https://github.com/Textualize/textual/pull/1524
 - `events.Paste` now bubbles https://github.com/Textualize/textual/issues/1434
+- `COMPONENT_CLASSES` are now inherited from base classes https://github.com/Textualize/textual/issues/1399
 
 ### Fixed
 
diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py
index 56ea05dc09..176a420a23 100644
--- a/src/textual/css/stylesheet.py
+++ b/src/textual/css/stylesheet.py
@@ -407,7 +407,7 @@ def apply(
         self.replace_rules(node, node_rules, animate=animate)
 
         node._component_styles.clear()
-        for component in node.COMPONENT_CLASSES:
+        for component in node.get_component_classes():
             virtual_node = DOMNode(classes=component)
             virtual_node._attach(node)
             self.apply(virtual_node, animate=False)
diff --git a/src/textual/dom.py b/src/textual/dom.py
index 1204073a0b..d2dcf88634 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -98,6 +98,9 @@ class DOMNode(MessagePump):
     # True if this node inherits the CSS from the base class.
     _inherit_css: ClassVar[bool] = True
 
+    # True if this node inherits the component classes from the base class.
+    _inherit_component_classes: ClassVar[bool] = True
+
     # True to inherit bindings from base class
     _inherit_bindings: ClassVar[bool] = True
 
@@ -279,6 +282,25 @@ def get_path(base: Type[DOMNode]) -> str:
 
         return css_stack
 
+    def get_component_classes(self) -> set[str]:
+        """Gets the component classes for this class and inherited from bases.
+
+        Component classes are inherited from base classes, unless
+        `_inherit_component_classes` is set to `False`.
+
+        Returns:
+            set[str]: a set with all the component classes available.
+        """
+
+        component_classes: set[str] = set()
+        for base in self._node_bases:
+            component_classes.update(base.__dict__.get("COMPONENT_CLASSES", set()))
+            if not base.__dict__.get("_inherit_component_classes", True):
+                break
+
+        return component_classes
+
+
     @property
     def parent(self) -> DOMNode | None:
         """DOMNode | None: The parent node."""
diff --git a/tests/test_dom.py b/tests/test_dom.py
index c1b5221c09..f9f5b50cfc 100644
--- a/tests/test_dom.py
+++ b/tests/test_dom.py
@@ -115,6 +115,53 @@ class E(D):
     assert e_css[1][1:] == ("C", -2)
 
 
+def test_component_classes_inheritance():
+    """Test if component classes are inherited properly."""
+
+    class A(DOMNode):
+        COMPONENT_CLASSES = {"a-1", "a-2"}
+
+    class B(A):
+        COMPONENT_CLASSES = {"b-1"}
+        _inherit_component_classes = False
+
+    class C(B):
+        COMPONENT_CLASSES = {"c-1", "c-2"}
+
+    class D(C):
+        pass
+
+    class E(D):
+        COMPONENT_CLASSES = {"e-1"}
+
+    class F(E):
+        COMPONENT_CLASSES = {"f-1"}
+        _inherit_component_classes = False
+
+    node = DOMNode()
+    node_cc = node.get_component_classes()
+    a = A()
+    a_cc = a.get_component_classes()
+    b = B()
+    b_cc = b.get_component_classes()
+    c = C()
+    c_cc = c.get_component_classes()
+    d = D()
+    d_cc = d.get_component_classes()
+    e = E()
+    e_cc = e.get_component_classes()
+    f = F()
+    f_cc = f.get_component_classes()
+
+    assert node_cc == set()
+    assert a_cc == {"a-1", "a-2"}
+    assert b_cc == {"b-1"}
+    assert c_cc == {"b-1", "c-1", "c-2"}
+    assert d_cc == c_cc
+    assert e_cc == {"b-1", "c-1", "c-2", "e-1"}
+    assert f_cc == {"f-1"}
+
+
 @pytest.fixture
 def search():
     """


Code review comments:
diff_hunk: @@ -279,6 +282,25 @@ def get_path(base: Type[DOMNode]) -> str:
 
         return css_stack
 
+    def get_component_classes(self) -> set[str]:
path: src/textual/dom.py
text: @user1:
Any reason why this should be public?

Unless there is a good reason to expose this to the dev, we should make it "private".

@author:
I made it public instead of private because `Stylesheet.apply` needs it and because this feels analogous to `get_default_css`, which is public.

@user2:
If this is staying "public", it might make sense to reword the docstring so that it doesn't refer to `_inherit_component_classes`.

@user1:
We use a more relaxed definition of public and private. Essentially `public` is something that the Textual dev may need to use. If it is only used by the Textual system then it should be `private`.

A better term, might be `internal`.

@author:
I reworded the docstring, good catch!

I don't think the average Textual dev will need to use `get_component_classes` but I did this in light of `get_default_css` because it is more or less the same level of thing.
Should I prefix both with an underscore `_`?

@user1:
Sure. May as well.

diff_hunk: @@ -115,6 +115,53 @@ class E(D):
     assert e_css[1][1:] == ("C", -2)
 
 
+def test_component_classes_inheritance():
+    """Test if component classes are inherited properly."""
+
+    class A(DOMNode):
+        COMPONENT_CLASSES = {"a-1", "a-2"}
+
+    class B(A):
+        COMPONENT_CLASSES = {"b-1"}
+        _inherit_component_classes = False
path: tests/test_dom.py
text: @user1:
Given that this will be useful for people using the library, wanting to inherit from a widget and decide if they want the component classes or not, does it make sense to have the inherit control switch be a "private" value?

@author:
Thanks for pointing this out! The problem is that I misunderstood how `_inherit_component_classes` should be set but Will's remarks clarified it for me.