diff --git a/fire/completion.py b/fire/completion.py index 1597d464..79e6ceb3 100644 --- a/fire/completion.py +++ b/fire/completion.py @@ -359,7 +359,7 @@ def VisibleMembers(component, class_attrs=None, verbose=False): if isinstance(component, dict): members = component.items() else: - members = inspect.getmembers(component) + members = inspectutils.GetSafeMembers(component) # If class_attrs has not been provided, compute it. if class_attrs is None: diff --git a/fire/core.py b/fire/core.py index 8e23e76b..8097a8f9 100644 --- a/fire/core.py +++ b/fire/core.py @@ -225,7 +225,7 @@ def _IsHelpShortcut(component_trace, remaining_args): _, remaining_kwargs, _ = _ParseKeywordArgs(remaining_args, fn_spec) show_help = target in remaining_kwargs else: - members = dict(inspect.getmembers(component)) + members = dict(inspectutils.GetSafeMembers(component)) show_help = target not in members if show_help: diff --git a/fire/inspectutils.py b/fire/inspectutils.py index 17508e30..6bddda6f 100644 --- a/fire/inspectutils.py +++ b/fire/inspectutils.py @@ -347,3 +347,33 @@ def IsCoroutineFunction(fn): return inspect.iscoroutinefunction(fn) except: # pylint: disable=bare-except return False + +def GetSafeMembers(component, predicate=None): + """Returns members of a component, skipping attributes that raise on access. + + Like inspect.getmembers, but catches all exceptions raised by property + getters or other dynamic attributes during member enumeration. Members + that raise are included with a value of None, preserving the member name + in the result so that callers can detect its presence without crashing. + + This behaviour differs from inspect.getmembers in Python 3.13+, which + only suppresses AttributeError and lets all other exceptions propagate. + + Args: + component: The object whose members to retrieve. + predicate: An optional predicate to filter members by value. + Returns: + A list of (name, value) pairs sorted by name. Members whose getters + raised are included as (name, None). + """ + results = [] + for key in dir(component): + try: + value = getattr(component, key) + except Exception: # pylint: disable=broad-except + value = None + if predicate and not predicate(value): + continue + results.append((key, value)) + results.sort(key=lambda pair: pair[0]) + return results diff --git a/fire/inspectutils_test.py b/fire/inspectutils_test.py index 47de7e72..0e250b90 100644 --- a/fire/inspectutils_test.py +++ b/fire/inspectutils_test.py @@ -125,6 +125,43 @@ def testInfoNoDocstring(self): info = inspectutils.Info(tc.NoDefaults) self.assertEqual(info['docstring'], None, 'Docstring should be None') + def testGetSafeMembersRaisingProperty(self): + class ComponentWithRaisingProperty: + @property + def status(self): + raise RuntimeError('backend unavailable') + + component = ComponentWithRaisingProperty() + members = dict(inspectutils.GetSafeMembers(component)) + self.assertIn('status', members) + self.assertIsNone(members['status']) + + def testGetSafeMembersWorkingProperty(self): + class ComponentWithWorkingProperty: + @property + def status(self): + return 'all good' + + component = ComponentWithWorkingProperty() + members = dict(inspectutils.GetSafeMembers(component)) + self.assertIn('status', members) + self.assertEqual(members['status'], 'all good') + + def testGetSafeMembersMixedProperties(self): + class ComponentWithMixedProperties: + @property + def good(self): + return 'ok' + @property + def bad(self): + raise ValueError('unavailable') + + component = ComponentWithMixedProperties() + members = dict(inspectutils.GetSafeMembers(component)) + self.assertIn('good', members) + self.assertEqual(members['good'], 'ok') + self.assertIn('bad', members) + self.assertIsNone(members['bad']) if __name__ == '__main__': testutils.main()