Repository: janluke/cloup (language: python)

Issue:
`cloup.Color` cannot be rendered by Sphinx: `Exception: you can't set attributes on this class`
#### Bug description

When trying to generate documentation with Sphinx, its `autodoc` plugin trips on `cloup.Color`. The latter cannot be rendered in Sphinx because [`cloup/_util.py::FrozenSpaceMeta.__setattr__()` is always raising an exception](https://github.com/janluke/cloup/blob/3899fbe66a5b339ea0bc78d181cbcea343b1bba5/cloup/_util.py#L128-L129).

#### To Reproduce

This happened in my [Click Extra](https://github.com/kdeldycke/click-extra) project, in which [I re-expose `cloup.Color` in my `click_extra` module via `__all__`](https://github.com/kdeldycke/click-extra/blob/fd51ad88836335304de7a2d72eac1078fe89b9ef/click_extra/__init__.py#L116-L122).

Here is the full traceback of Sphinx trying to generate the documentation of Click Extra:

```shell-session
$ poetry run sphinx-build -b html ./docs ./docs/html
# Platform:         darwin; (macOS-14.3.1-arm64-64bit)
# Sphinx version:   7.1.2
# Python version:   3.12.1 (CPython)
# Docutils version: 0.20.1
# Jinja2 version:   3.1.3
# Pygments version: 2.17.2

# Last messages:
#   [config changed ('version')]
#   22 added, 0 changed, 0 removed

#   reading sources... [  5%]
#   changelog
#   

#   reading sources... [  9%]
#   click_extra
#   

# Loaded extensions:
#   sphinx.ext.mathjax (7.1.2)
#   alabaster (0.7.13)
#   sphinxcontrib.applehelp (1.0.4)
#   sphinxcontrib.devhelp (1.0.2)
#   sphinxcontrib.htmlhelp (2.0.1)
#   sphinxcontrib.serializinghtml (1.1.5)
#   sphinxcontrib.qthelp (1.0.3)
#   sphinx.ext.autodoc.preserve_defaults (7.1.2)
#   sphinx.ext.autodoc.type_comment (7.1.2)
#   sphinx.ext.autodoc.typehints (7.1.2)
#   sphinx.ext.autodoc (7.1.2)
#   sphinx.ext.todo (7.1.2)
#   sphinx.ext.extlinks (7.1.2)
#   sphinx.ext.intersphinx (7.1.2)
#   sphinx.ext.viewcode (7.1.2)
#   sphinx_copybutton (0.5.2)
#   sphinx_design (0.5.0)
#   sphinx_issues (4.0.0)
#   sphinxext.opengraph (unknown version)
#   myst_parser (2.0.0)
#   sphinx.ext.autosectionlabel (7.1.2)
#   sphinx_autodoc_typehints (unknown version)
#   click_extra.sphinx (unknown version)
#   sphinxcontrib.mermaid (7.1.2)
#   furo (2024.01.29)
#   sphinx_basic_ng (1.0.0.beta2)

# Traceback:
```

```pytb
Traceback (most recent call last):
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/cmd/build.py", line 290, in build_main
    app.build(args.force_all, args.filenames)
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/application.py", line 351, in build
    self.builder.build_update()
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/builders/__init__.py", line 290, in build_update
    self.build(to_build,
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/builders/__init__.py", line 310, in build
    updated_docnames = set(self.read())
                           ^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/builders/__init__.py", line 417, in read
    self._read_serial(docnames)
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/builders/__init__.py", line 438, in _read_serial
    self.read_doc(docname)
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/builders/__init__.py", line 494, in read_doc
    publisher.publish()
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/core.py", line 234, in publish
    self.document = self.reader.read(self.source, self.parser,
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/io.py", line 104, in read
    self.parse()
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/readers/__init__.py", line 76, in parse
    self.parser.parse(self.input, document)
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/parsers.py", line 80, in parse
    self.statemachine.run(inputlines, document, inliner=self.inliner)
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/parsers/rst/states.py", line 169, in run
    results = StateMachineWS.run(self, input_lines, input_offset,
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/statemachine.py", line 233, in run
    context, next_state, result = self.check_line(
                                  ^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/statemachine.py", line 445, in check_line
    return method(match, context, next_state)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/parsers/rst/states.py", line 2785, in underline
    self.section(title, source, style, lineno - 1, messages)
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/parsers/rst/states.py", line 325, in section
    self.new_subsection(title, lineno, messages)
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/parsers/rst/states.py", line 391, in new_subsection
    newabsoffset = self.nested_parse(
                   ^^^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/parsers/rst/states.py", line 279, in nested_parse
    state_machine.run(block, input_offset, memo=self.memo,
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/parsers/rst/states.py", line 195, in run
    results = StateMachineWS.run(self, input_lines, input_offset)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/statemachine.py", line 233, in run
    context, next_state, result = self.check_line(
                                  ^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/statemachine.py", line 445, in check_line
    return method(match, context, next_state)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/parsers/rst/states.py", line 2355, in explicit_markup
    nodelist, blank_finish = self.explicit_construct(match)
                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/parsers/rst/states.py", line 2367, in explicit_construct
    return method(self, expmatch)
           ^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/parsers/rst/states.py", line 2104, in directive
    return self.run_directive(
           ^^^^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/docutils/parsers/rst/states.py", line 2154, in run_directive
    result = directive_instance.run()
             ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/ext/autodoc/directive.py", line 135, in run
    documenter.generate(more_content=self.content)
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/ext/autodoc/__init__.py", line 950, in generate
    self.document_members(all_members)
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/ext/autodoc/__init__.py", line 824, in document_members
    documenter.generate(
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/ext/autodoc/__init__.py", line 1871, in generate
    return super().generate(more_content=more_content,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/ext/autodoc/__init__.py", line 950, in generate
    self.document_members(all_members)
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/ext/autodoc/__init__.py", line 1857, in document_members
    super().document_members(all_members)
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/ext/autodoc/__init__.py", line 824, in document_members
    documenter.generate(
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/ext/autodoc/__init__.py", line 879, in generate
    if not self.import_object():
           ^^^^^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/ext/autodoc/__init__.py", line 2575, in import_object
    self.update_annotations(self.parent)
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/sphinx/ext/autodoc/__init__.py", line 2552, in update_annotations
    parent.__annotations__ = annotations
    ^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/kde/click-extra/.venv/lib/python3.12/site-packages/cloup/_util.py", line 129, in __setattr__
    raise Exception("you can't set attributes on this class")
Exception: you can't set attributes on this class
```



Discussion / hints:
This should be reported to autodoc maintainers, there's no bug here. I guess we can add an exception for `__annotations__` in Cloup to solve this quickly. We could also change Color to be extends both `str` and `Enum` (but I guess that would be formally a breaking change).
Thanks @janluke for the quick reply. You're right, so I opened an issue upstream in Sphinx at: https://github.com/sphinx-doc/sphinx/issues/11986 . We'll see if it get some attention.


Fix patch:
diff --git a/cloup/_util.py b/cloup/_util.py
index e5ed634..fff8de0 100644
--- a/cloup/_util.py
+++ b/cloup/_util.py
@@ -126,7 +126,13 @@ def __init__(cls, *args: Any):
         type.__setattr__(cls, '_dict', d)
 
     def __setattr__(cls, key: str, value: Any) -> None:
-        raise Exception("you can't set attributes on this class")
+        if key.startswith("__"):
+            return super().__setattr__(key, value)
+        else:
+            raise Exception(
+                "you can't set attributes on this class; only special dunder attributes "
+                "(e.g. __annotations__) are allowed to be set for compatibility reasons."
+            )
 
     def asdict(cls) -> Dict[str, Any]:
         return cls._dict  # type: ignore


Test patch:
diff --git a/tests/test_styling.py b/tests/test_styling.py
index 9b98398..73c5eca 100644
--- a/tests/test_styling.py
+++ b/tests/test_styling.py
@@ -6,14 +6,14 @@
 from cloup.styling import Color, HelpTheme, Style
 
 
-def test_help_theme_copywith():
+def test_help_theme_copy_with():
     s1, s2 = Style(), Style()
     r1, r2 = Style(), Style()
     theme = HelpTheme(heading=s1, col1=s2).with_(col1=r1, col2=r2)
     assert theme == HelpTheme(heading=s1, col1=r1, col2=r2)
 
 
-def test_help_theme_copywith_takes_the_same_parameters_of_constructor():
+def test_help_theme_copy_with_takes_the_same_parameters_of_constructor():
     def get_param_names(func):
         params = inspect.signature(func).parameters
         return list(params.keys())
@@ -38,17 +38,25 @@ def test_unsupported_style_args_are_ignored_in_click_7():
     Style(overline=True, italic=True, strikethrough=True)
 
 
-def test_Color():
+def test_color_class():
+    # Check values of some attributes
     assert Color.red == 'red'
     assert Color.bright_blue == 'bright_blue'
 
+    # Check it's not instantiable
     with pytest.raises(Exception, match="it's not instantiable"):
         Color()
+
+    # Check only __dunder__ fields are settable
+    Color.__annotations__ = "whatever"
     with pytest.raises(Exception, match="you can't set attributes on this class"):
         Color.red = 'blue'
 
+    # Test __contains__
     assert 'red' in Color
     assert 'pippo' not in Color
+
+    # Test Color.asdict()
     d = Color.asdict()
     for k, v in d.items():
         assert k in Color