Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions libcst/matchers/_visitors.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,23 @@ def is_property(obj: object, attr_name: str) -> bool:
return isinstance(getattr(type(obj), attr_name, None), property)


_ATTR_MISSING = object()


def _safe_getattr(obj: object, attr_name: str) -> object:
"""Fetch obj.attr, returning a sentinel if the descriptor refuses access.

Names reported by ``dir()`` are not guaranteed to be retrievable: a
descriptor may raise ``AttributeError`` on access (zope.interface's
``__provides__`` does this). Such names cannot be visit/leave methods,
so they are skipped instead of crashing the visitor construction.
"""
try:
return getattr(obj, attr_name)
except AttributeError:
return _ATTR_MISSING


# pyre-ignore We don't care about Any here, its not exposed.
def _match_decorator_unpickler(kwargs: Any) -> "MatchDecoratorMismatch":
return MatchDecoratorMismatch(**kwargs)
Expand Down Expand Up @@ -281,7 +298,9 @@ def _gather_matchers(obj: object) -> Dict[BaseMatcherNode, Optional[cst.CSTNode]

for attr_name in dir(obj):
if not is_property(obj, attr_name):
func = getattr(obj, attr_name)
func = _safe_getattr(obj, attr_name)
if func is _ATTR_MISSING:
continue
for matcher in getattr(func, VISIT_POSITIVE_MATCHER_ATTR, []):
visit_matchers[cast(BaseMatcherNode, matcher)] = None
for matcher in getattr(func, VISIT_NEGATIVE_MATCHER_ATTR, []):
Expand Down Expand Up @@ -311,7 +330,7 @@ def _gather_constructed_visit_funcs(
for funcname in dir(obj):
if is_property(obj, funcname):
continue
possible_func = getattr(obj, funcname)
possible_func = _safe_getattr(obj, funcname)
if not ismethod(possible_func):
continue
func = cast(Callable[[cst.CSTNode], None], possible_func)
Expand Down Expand Up @@ -342,7 +361,7 @@ def _gather_constructed_leave_funcs(
for funcname in dir(obj):
if is_property(obj, funcname):
continue
possible_func = getattr(obj, funcname)
possible_func = _safe_getattr(obj, funcname)
if not ismethod(possible_func):
continue
func = cast(Callable[[cst.CSTNode], None], possible_func)
Expand Down
19 changes: 19 additions & 0 deletions libcst/matchers/tests/test_visitors.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,3 +508,22 @@ def test_pickleable_exception(self) -> None:
unserialized = pickle.loads(serialized)
self.assertEqual(original.message, unserialized.message)
self.assertEqual(original.func, unserialized.func)

def test_inaccessible_attribute_is_skipped(self) -> None:
# Names reported by dir() are not necessarily retrievable: zope.interface
# installs a __provides__ descriptor that raises AttributeError on access.
# Constructing a visitor must not propagate that error.
class RaisingDescriptor:
def __get__(self, obj: object, objtype: object = None) -> object:
raise AttributeError("__provides__")

class TestVisitor(MatcherDecoratableTransformer):
__provides__ = RaisingDescriptor()

@visit(m.SimpleString())
def _string_visit(self, node: cst.SimpleString) -> None:
pass

visitor = TestVisitor()
self.assertIn("__provides__", dir(visitor))
self.assertEqual(len(visitor._matchers), 0)