Skip to content

Commit

Permalink
Respect ClassVar variable annotation
Browse files Browse the repository at this point in the history
According to [PEP 526](https://peps.python.org/pep-0526/#class-and-instance-variable-annotations)
annotated attributes in a class body are instance variables by default,
unless explicitly overridden by using
[`typing.ClassVar`](https://docs.python.org/3/library/typing.html#typing.ClassVar).
  • Loading branch information
viccie30 committed Apr 17, 2023
1 parent 70d9b93 commit 5dc444a
Show file tree
Hide file tree
Showing 3 changed files with 51 additions and 3 deletions.
15 changes: 12 additions & 3 deletions src/griffe/agents/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,14 @@
Parameters,
)
from griffe.exceptions import AliasResolutionError, CyclicAliasError, LastNodeError, NameResolutionError
from griffe.expressions import Expression
from griffe.extensions import Extensions

if TYPE_CHECKING:
from pathlib import Path

from griffe.docstrings.parsers import Parser
from griffe.expressions import Expression, Name
from griffe.expressions import Name


builtin_decorators = {
Expand Down Expand Up @@ -557,7 +558,13 @@ def handle_attribute(
names = get_names(node)
except KeyError: # unsupported nodes, like subscript
return
labels.add("class-attribute")

if isinstance(annotation, Expression) and annotation.is_classvar:
annotation = annotation[2]
labels.add("class-attribute")
else:
labels.add("instance-attribute")

elif parent.kind is Kind.FUNCTION:
if parent.name != "__init__":
return
Expand Down Expand Up @@ -592,9 +599,11 @@ def handle_attribute(

with suppress(AliasResolutionError, CyclicAliasError):
labels |= parent.members[name].labels # type: ignore[misc]
# forward previous docstring instead of erasing it
# forward previous docstring and annotation instead of erasing them
if parent.members[name].docstring and not docstring:
docstring = parent.members[name].docstring
if parent.attributes[name].annotation and not annotation:
annotation = parent.attributes[name].annotation

attribute = Attribute(
name=name,
Expand Down
9 changes: 9 additions & 0 deletions src/griffe/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,15 @@ def is_generator(self) -> bool:
"""
return self.kind == "generator"

@property
def is_classvar(self) -> bool:
"""Tell whether this expression represents a ClassVar.
Returns:
True or False.
"""
return isinstance(self[0], Name) and self[0].full == "typing.ClassVar"

@cached_property
def non_optional(self) -> Expression:
"""Return the same expression as non-optional.
Expand Down
30 changes: 30 additions & 0 deletions tests/test_visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,3 +297,33 @@ def __init__(self, attr: int) -> None:
''',
) as module:
assert module["C.attr"].docstring

def test_classvar_annotations() -> None:
"""Assert class variable and instance variable annotations are correctly parsed and merged."""
with temporary_visited_module(
"""
from typing import ClassVar
class C:
x: ClassVar[int]
y: str
def __init__(self) -> None:
self.z: ClassVar[float]
self.y = ""
self.a: bytes
""",
) as module:
assert module["C.x"].annotation.full == "int"
assert module["C.x"].labels == ["class-attribute"]

assert module["C.y"].annotation.full == "str"
assert module["C.y"].labels == ["instance-attribute"]

# This is syntactically valid, but semantically invalid
assert module["C.z"].annotation[0].full == "typing.ClassVar"
assert module["C.z"].annotation[2].full == "str"
assert module["C.y"].labels == ["instance-attribute"]

assert module["C.a"].annotation.full == "bytes"
assert module["C.y"].labels == ["instance-attribute"]

0 comments on commit 5dc444a

Please sign in to comment.