Coverage for src/bluetooth_sig/gatt/registry_utils.py: 95%

63 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-28 01:26 +0000

1"""Common utilities for GATT registries. 

2 

3This module contains shared utility classes used by both characteristic and 

4service registries to avoid code duplication. 

5""" 

6 

7from __future__ import annotations 

8 

9import inspect 

10import pkgutil 

11from collections.abc import Callable 

12from importlib import import_module 

13from typing import Any, TypeGuard, TypeVar 

14 

15 

16class TypeValidator: # pylint: disable=too-few-public-methods 

17 """Utility class for type validation operations. 

18 

19 Note: Utility class pattern with static methods - pylint disable justified. 

20 """ 

21 

22 @staticmethod 

23 def is_subclass_of(candidate: object, base_class: type) -> TypeGuard[type]: 

24 """Return True when candidate is a subclass of base_class. 

25 

26 Args: 

27 candidate: Object to check 

28 base_class: Base class to check against 

29 

30 Returns: 

31 True if candidate is a subclass of base_class 

32 

33 """ 

34 return isinstance(candidate, type) and issubclass(candidate, base_class) 

35 

36 

37T = TypeVar("T") 

38 

39 

40class ModuleDiscovery: 

41 """Base class for discovering classes in a package using pkgutil.walk_packages. 

42 

43 This utility provides a common pattern for discovering and validating classes 

44 across different GATT packages (characteristics and services). 

45 """ 

46 

47 @staticmethod 

48 def iter_module_names( 

49 package_name: str, 

50 module_exclusions: set[str], 

51 ) -> list[str]: 

52 """Return sorted module names discovered via pkgutil.walk_packages. 

53 

54 Args: 

55 package_name: Fully qualified package name (e.g., "bluetooth_sig.gatt.characteristics") 

56 module_exclusions: Set of module basenames to exclude (e.g., {"__init__", "base"}) 

57 

58 Returns: 

59 Sorted list of module names found in the package 

60 

61 References: 

62 Python standard library documentation, pkgutil.walk_packages, 

63 https://docs.python.org/3/library/pkgutil.html#pkgutil.walk_packages 

64 

65 """ 

66 package = import_module(package_name) 

67 module_names: list[str] = [] 

68 prefix = f"{package_name}." 

69 for module_info in pkgutil.walk_packages(package.__path__, prefix): 

70 module_basename = module_info.name.rsplit(".", 1)[-1] 

71 if module_basename in module_exclusions: 

72 continue 

73 module_names.append(module_info.name) 

74 module_names.sort() 

75 return module_names 

76 

77 @staticmethod 

78 def discover_classes( 

79 module_names: list[str], 

80 base_class: type[T], 

81 validator: Callable[[Any], bool], 

82 ) -> list[type[T]]: 

83 """Discover all concrete classes in modules that pass validation. 

84 

85 Args: 

86 module_names: List of module names to search 

87 base_class: Base class type for filtering 

88 validator: Function to validate if object is a valid subclass 

89 

90 Returns: 

91 Sorted list of discovered classes 

92 

93 """ 

94 discovered: list[type[T]] = [] 

95 for module_name in module_names: 

96 module = import_module(module_name) 

97 candidates: list[type[T]] = [] 

98 for _, obj in inspect.getmembers(module, inspect.isclass): 

99 if not validator(obj): 

100 continue 

101 if obj is base_class or getattr(obj, "_is_template", False): 

102 continue 

103 if getattr(obj, "_is_base_class", False): 

104 continue # Skip base classes that require parameters 

105 if obj.__module__ != module.__name__: 

106 continue 

107 

108 # Validate that the class has required methods 

109 if not hasattr(obj, "get_class_uuid") or not callable(obj.get_class_uuid): 

110 continue # Skip classes without proper UUID resolution 

111 

112 candidates.append(obj) 

113 candidates.sort(key=lambda cls: cls.__name__) 

114 discovered.extend(candidates) 

115 return discovered 

116 

117 @staticmethod 

118 def build_lazy_export_map( 

119 package_name: str, 

120 module_exclusions: set[str], 

121 base_class: type[object], 

122 ) -> dict[str, str]: 

123 """Build a PEP 562 lazy export map for concrete classes in *package_name*. 

124 

125 Args: 

126 package_name: Fully qualified package name. 

127 module_exclusions: Module basenames to skip during discovery. 

128 base_class: Base class that exportable types must subclass. 

129 

130 Returns: 

131 Mapping of public class name to defining module path, sorted by name. 

132 """ 

133 export_map: dict[str, str] = {} 

134 for module_name in ModuleDiscovery.iter_module_names(package_name, module_exclusions): 

135 module = import_module(module_name) 

136 for _, obj in inspect.getmembers(module, inspect.isclass): 

137 if obj.__module__ != module.__name__: 

138 continue 

139 if obj is base_class: 

140 continue 

141 if getattr(obj, "_is_template", False): 

142 continue 

143 if getattr(obj, "_is_base_class", False): 

144 continue 

145 if not isinstance(obj, type) or not issubclass(obj, base_class): 

146 continue 

147 export_map[obj.__name__] = module_name 

148 return dict(sorted(export_map.items()))