From 1b990412e35dc967ded8e8510218d38a81095b77 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 22 Oct 2026 22:26:54 +0300 Subject: [PATCH] fix: label unannotated type variables as typevars A type variable declared as `T = TypeVar("T")`, the usual spelling, was badged as a constant because only its annotation was inspected. The declared value now decides, and `ParamSpec` and `TypeVarTuple` count too, so the typing page also lists them under Type Variables. The typevar badge gains its text and a colour of its own; it previously rendered empty. --- great_docs/_apiref/_render/_label.py | 3 +++ great_docs/_apiref/_type_checks.py | 17 ++++++++++++----- great_docs/assets/great-docs.scss | 8 ++++++++ tests/renderer/test_apiref_internals.py | 15 +++++++++++++++ tests/renderer/test_type_aliases.py | 16 ++++++++++++---- tests/test_gdg_rendered.py | 11 ++++++++++- 6 files changed, 60 insertions(+), 10 deletions(-) diff --git a/great_docs/_apiref/_render/_label.py b/great_docs/_apiref/_render/_label.py index 78b0bf05..dc7bf1c6 100644 --- a/great_docs/_apiref/_render/_label.py +++ b/great_docs/_apiref/_render/_label.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING +from .._type_checks import is_typevar + if TYPE_CHECKING: import griffe as gf @@ -108,6 +110,7 @@ def _attribute_label(obj: gf.Attribute) -> str: "TypeVar" in annotation_text or "ParamSpec" in annotation_text or "TypeVarTuple" in annotation_text + or is_typevar(obj) ): return "typevar" elif "property" in obj.labels: diff --git a/great_docs/_apiref/_type_checks.py b/great_docs/_apiref/_type_checks.py index ae8e556c..c096369a 100644 --- a/great_docs/_apiref/_type_checks.py +++ b/great_docs/_apiref/_type_checks.py @@ -135,16 +135,23 @@ def is_enum(obj: gf.Object | gf.Alias) -> bool: ) +_TYPE_VARIABLE_KINDS = frozenset({"TypeVar", "ParamSpec", "TypeVarTuple"}) + + def is_typevar(obj: gf.Object | gf.Alias) -> bool: """ - Whether `obj` is a declaration of a `TypeVar` + Whether `obj` is a declaration of a type variable + + A type variable is a `TypeVar`, `ParamSpec` or `TypeVarTuple`, called by + its bare name or through any module, such as `typing.TypeVar`. """ target = _resolved(obj) + if not (isinstance(target, gf.Attribute) and isinstance(target.value, gf.ExprCall)): + return False + function = target.value.function return ( - isinstance(target, gf.Attribute) - and isinstance(target.value, gf.ExprCall) - and isinstance(target.value.function, gf.ExprName) - and target.value.function.name == "TypeVar" + isinstance(function, (gf.ExprName, gf.ExprAttribute)) + and function.canonical_path.rpartition(".")[2] in _TYPE_VARIABLE_KINDS ) diff --git a/great_docs/assets/great-docs.scss b/great_docs/assets/great-docs.scss index 3749343a..abb297f7 100644 --- a/great_docs/assets/great-docs.scss +++ b/great_docs/assets/great-docs.scss @@ -19,6 +19,7 @@ $label-exception-color: mix($primary, $red-600, 20%); $label-property-color: mix($primary, $teal-600, 20%); $label-constant-color: mix($primary, $orange-500, 20%); $label-typealias-color: mix($primary, $yellow-500, 20%); +$label-typevar-color: mix($primary, $purple-500, 20%); $label-cli-color: mix($primary, $green-600, 20%); $label-cli-group-color: mix($primary, $green-600, 20%); $label-mcp-tool-color: #e6194B; @@ -555,6 +556,7 @@ $all_labels: ( property, constant, typealias, + typevar, cli, cli-group, mcp-tool, @@ -632,6 +634,12 @@ $method-like-labels: (method, classmethod, staticmethod); background-color: tint-color($label-typealias-color, 95%); } +:is(.content, .quarto-secondary-nav-title) span.doc-label-typevar::after { + color: $label-typevar-color; + border-color: $label-typevar-color; + background-color: tint-color($label-typevar-color, 95%); +} + :is(.content, .quarto-secondary-nav-title) span.doc-label-cli::after { color: $label-cli-color; border-color: $label-cli-color; diff --git a/tests/renderer/test_apiref_internals.py b/tests/renderer/test_apiref_internals.py index ca501eab..05ec8fc4 100644 --- a/tests/renderer/test_apiref_internals.py +++ b/tests/renderer/test_apiref_internals.py @@ -125,6 +125,21 @@ def test_is_typevar_follows_a_reexported_typevar(): assert is_typevar(_reexported(source, "T")) is True +@pytest.mark.parametrize( + "source", + [ + 'import typing\nT = typing.TypeVar("T")\n', + 'import typing as t\nT = t.TypeVar("T")\n', + 'import typing_extensions\nT = typing_extensions.TypeVar("T")\n', + 'from typing import ParamSpec\nT = ParamSpec("T")\n', + 'from typing import TypeVarTuple\nT = TypeVarTuple("T")\n', + ], +) +def test_is_typevar_recognises_qualified_and_variadic_spellings(source: str): + """Recognise every kind of type variable under any import spelling""" + assert is_typevar(_reexported(source, "T")) is True + + def test_classification_of_an_unloadable_target_is_false(): """Return `False` when an alias target cannot be loaded""" modules = {"__init__.py": "from never_imported_package import Contract\n"} diff --git a/tests/renderer/test_type_aliases.py b/tests/renderer/test_type_aliases.py index 994dbd93..f9e71d50 100644 --- a/tests/renderer/test_type_aliases.py +++ b/tests/renderer/test_type_aliases.py @@ -77,12 +77,20 @@ def test_label_legacy_spelling(): assert get_label(obj) == "typealias" -def test_label_bare_typevar_is_constant(): - """A bare `T = TypeVar("T")` has no annotation, so it labels as a constant""" +@pytest.mark.parametrize( + ("code", "name"), + [ + ('from typing import TypeVar\nT = TypeVar("T")\n', "T"), + ('import typing\nT = typing.TypeVar("T")\n', "T"), + ('from typing import ParamSpec\nP = ParamSpec("P")\n', "P"), + ('from typing import TypeVarTuple\nTs = TypeVarTuple("Ts")\n', "Ts"), + ], +) +def test_label_type_variable_without_annotation(code: str, name: str): + """Label a type variable by its declared value when it has no annotation""" from great_docs._apiref._render._label import get_label - code = 'from typing import TypeVar\nT = TypeVar("T")\n' - assert get_label(_load_member(code, "T")) == "constant" + assert get_label(_load_member(code, name)) == "typevar" def test_label_plain_constant_still_works(): diff --git a/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index ee14feb6..8b9b550e 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -506,6 +506,7 @@ def test_reference_pages_have_type_badge(pkg_name: str): "constant", "type_alias", "typealias", + "typevar", "dataclass", "exception", "protocol", @@ -551,7 +552,15 @@ def test_function_pages_have_signature(pkg_name: str): if badge_text is None: continue - if badge_text in ("constant", "type_alias", "typealias", "enum", "namedtuple", "typeddict"): + if badge_text in ( + "constant", + "type_alias", + "typealias", + "typevar", + "enum", + "namedtuple", + "typeddict", + ): continue # Callable names use `sig-name` in `plain` signatures and `fu` in highlighted ones.