diff --git a/src/griffe/mixins.py b/src/griffe/mixins.py index f52b4f0f..f5baa89d 100644 --- a/src/griffe/mixins.py +++ b/src/griffe/mixins.py @@ -299,6 +299,11 @@ def has_private_name(self) -> bool: """Whether this object/alias has a private name.""" return self.name.startswith("_") # type: ignore[attr-defined] + @property + def has_special_name(self) -> bool: + """Whether this object/alias has a special name.""" + return self.name.startswith("__") and self.name.endswith("__") # type: ignore[attr-defined] + @property def is_exported(self) -> bool: """Whether this object/alias is exported (listed in `__all__`).""" @@ -365,18 +370,30 @@ def is_public(self) -> bool: - Otherwise, the object is public. """ # TODO: Return regular True/False values in next version. + + # Give priority to the `public` attribute if it is set. if self.public is not None: # type: ignore[attr-defined] return _True if self.public else _False # type: ignore[return-value,attr-defined] + + # If the object is defined at the module-level and is listed in `__all__`, it is public. + # If the parent module defines `__all__` but does not list the object, it is private. if self.parent and self.parent.is_module and bool(self.parent.exports): # type: ignore[attr-defined] return _True if self.name in self.parent.exports else _False # type: ignore[attr-defined,return-value] - if self.has_private_name: + + # Special objects are always considered public. + # Even if we don't access them directly, they are used through different *public* means + # like instantiating classes (`__init__`), using operators (`__eq__`), etc.. + if self.has_private_name and not self.has_special_name: return _False # type: ignore[return-value] - # The following condition effectively filters out imported objects. + # TODO: In a future version, we will support two conventions regarding imports: # - `from a import x as x` marks `x` as public. # - `from a import *` marks all wildcard imported objects as public. + # The following condition effectively filters out imported objects. if self.is_alias and not (self.inherited or (self.parent and self.parent.is_alias)): # type: ignore[attr-defined] return _False # type: ignore[return-value] + + # If we reached this point, the object is public. return _True # type: ignore[return-value] diff --git a/tests/test_public_api.py b/tests/test_public_api.py index cbdbbf93..4deba9db 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -8,3 +8,16 @@ def test_not_detecting_imported_objects_as_public() -> None: with temporary_visited_module("from abc import ABC\ndef func(): ...") as module: assert not module["ABC"].is_public assert module["func"].is_public # control case + + +def test_detecting_dunder_attributes_as_public() -> None: + """Dunder attributes (methods, etc.) must be considered public.""" + with temporary_visited_module( + """ + def __getattr__(name): ... + class A: + def __init__(self): ... + """, + ) as module: + assert module["__getattr__"].is_public + assert module["A.__init__"].is_public