Coverage for src/bluetooth_sig/registry/base.py: 84%

219 statements  

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

1"""Base registry class for Bluetooth SIG registries with UUID support.""" 

2 

3from __future__ import annotations 

4 

5import threading 

6from abc import ABC, abstractmethod 

7from collections.abc import Callable 

8from enum import Enum 

9from pathlib import Path 

10from typing import Any, ClassVar, Generic, TypeVar, cast 

11 

12from bluetooth_sig.registry.utils import ( 

13 find_bluetooth_sig_path, 

14 load_yaml_uuids, 

15 normalize_uuid_string, 

16) 

17from bluetooth_sig.types.registry import BaseUuidInfo, generate_basic_aliases 

18from bluetooth_sig.types.uuid import BluetoothUUID 

19 

20T = TypeVar("T") 

21E = TypeVar("E", bound=Enum) # For enum-keyed registries 

22C = TypeVar("C") # For class types 

23BG = TypeVar("BG", bound="BaseGenericRegistry[Any]") 

24BU = TypeVar("BU", bound="BaseUUIDRegistry[Any]") 

25BC = TypeVar("BC", bound="BaseUUIDClassRegistry[Any, Any]") 

26 

27 

28class RegistryMixin: 

29 """Mixin providing common registry patterns for singleton, thread safety, and lazy loading. 

30 

31 This mixin contains shared functionality used by both info-based and class-based registries. 

32 """ 

33 

34 _lock: threading.RLock 

35 _loaded: bool 

36 

37 def _lazy_load(self, loaded_check: Callable[[], bool], loader: Callable[[], None]) -> bool: 

38 """Thread-safe lazy loading helper using double-checked locking pattern. 

39 

40 Args: 

41 loaded_check: Callable that returns True if data is already loaded 

42 loader: Callable that performs the actual loading 

43 

44 Returns: 

45 True if loading was performed, False if already loaded 

46 """ 

47 if loaded_check(): 

48 return False 

49 

50 with self._lock: 

51 # Double-check after acquiring lock for thread safety 

52 if loaded_check(): 

53 return False 

54 

55 loader() 

56 return True 

57 

58 def _ensure_loaded(self) -> None: 

59 """Ensure the registry is loaded (thread-safe lazy loading). 

60 

61 This is a standard implementation that subclasses can use. 

62 It calls _lazy_load with self._loaded check and self._load as the loader. 

63 Subclasses that need custom behaviour can override this method. 

64 """ 

65 self._lazy_load(lambda: self._loaded, self._load) 

66 

67 def ensure_loaded(self) -> None: 

68 """Public API to eagerly load the registry. 

69 

70 Equivalent to :meth:`_ensure_loaded` but intended for consumers 

71 that need to pre-warm registries (e.g. during application startup). 

72 """ 

73 self._ensure_loaded() 

74 

75 @abstractmethod 

76 def _load(self) -> None: 

77 """Perform the actual loading of registry data.""" 

78 

79 

80class BaseGenericRegistry(RegistryMixin, ABC, Generic[T]): 

81 """Base class for generic Bluetooth SIG registries with singleton pattern and thread safety. 

82 

83 For registries that are not UUID-based. 

84 """ 

85 

86 _instance: ClassVar[BaseGenericRegistry[Any] | None] = None 

87 _instance_lock: ClassVar[threading.RLock] = threading.RLock() 

88 

89 def __init__(self) -> None: 

90 """Initialize the registry.""" 

91 self._lock = threading.RLock() 

92 self._loaded: bool = False 

93 

94 @classmethod 

95 def get_instance(cls: type[BG]) -> BG: 

96 """Get the singleton instance of the registry.""" 

97 if cls._instance is None: 

98 with cls._instance_lock: 

99 if cls._instance is None: 

100 cls._instance = cls() 

101 return cast("BG", cls._instance) 

102 

103 

104U = TypeVar("U", bound=BaseUuidInfo) 

105 

106 

107class BaseUUIDRegistry(RegistryMixin, ABC, Generic[U]): 

108 """Base class for Bluetooth SIG registries with singleton pattern, thread safety, and UUID support. 

109 

110 Provides canonical storage, alias indices, and extensible hooks for UUID-based registries. 

111 

112 Subclasses should: 

113 1. Call super().__init__() in their __init__ (base class sets self._loaded = False) 

114 2. Implement _load() to perform actual data loading (must set self._loaded = True when done) 

115 3. Optionally override _load_yaml_path() to return the YAML file path relative to bluetooth_sig/ 

116 4. Optionally override _generate_aliases(info) for domain-specific alias heuristics 

117 5. Optionally override _post_store(info) for enrichment (e.g., unit mappings) 

118 6. Call _ensure_loaded() before accessing data (provided by base class) 

119 """ 

120 

121 _instance: ClassVar[BaseUUIDRegistry[Any] | None] = None 

122 _instance_lock: ClassVar[threading.RLock] = threading.RLock() 

123 

124 def __init__(self) -> None: 

125 """Initialize the registry.""" 

126 self._lock = threading.RLock() 

127 self._loaded: bool = False # Initialized in base class, accessed by subclasses 

128 # Performance: Use str keys instead of BluetoothUUID for O(1) dict lookups 

129 # String hashing is faster and these dicts are accessed frequently 

130 self._canonical_store: dict[str, U] = {} # normalized_uuid -> info 

131 self._alias_index: dict[str, str] = {} # lowercased_alias -> normalized_uuid 

132 self._runtime_overrides: dict[str, U] = {} # normalized_uuid -> original SIG info 

133 

134 @abstractmethod 

135 def _load_yaml_path(self) -> str: 

136 """Return the YAML file path relative to bluetooth_sig/ root.""" 

137 

138 def _generate_aliases(self, info: U) -> set[str]: 

139 """Generate alias keys for an info entry. 

140 

141 Default implementation uses conservative basic aliases. 

142 Subclasses can override for domain-specific heuristics. 

143 """ 

144 return generate_basic_aliases(info) 

145 

146 def _post_store(self, info: U) -> None: 

147 """Perform post-store enrichment for an info entry. 

148 

149 Default implementation does nothing. 

150 Subclasses can override for enrichment (e.g., cross-references). 

151 """ 

152 

153 def _store_info(self, info: U) -> None: 

154 """Store info with canonical key and generate aliases.""" 

155 canonical_key = info.uuid.normalized 

156 

157 # Store in canonical location 

158 self._canonical_store[canonical_key] = info 

159 

160 # Generate and store aliases 

161 aliases = self._generate_aliases(info) 

162 for alias in aliases: 

163 self._alias_index[alias.lower()] = canonical_key 

164 

165 # Perform any post-store enrichment 

166 self._post_store(info) 

167 

168 def _load_from_yaml(self, yaml_path: Path) -> None: 

169 """Load UUIDs from YAML file and store them.""" 

170 for uuid_data in load_yaml_uuids(yaml_path): 

171 uuid_str = normalize_uuid_string(uuid_data["uuid"]) 

172 bt_uuid = BluetoothUUID(uuid_str) 

173 

174 # Create info with available fields, defaults for missing 

175 info = self._create_info_from_yaml(uuid_data, bt_uuid) 

176 self._store_info(info) 

177 

178 def _load(self) -> None: 

179 """Perform the actual loading of registry data from YAML. 

180 

181 Default implementation loads from the path returned by _load_yaml_path(). 

182 Subclasses can override for custom loading behaviour. 

183 """ 

184 base_path = find_bluetooth_sig_path() 

185 if base_path: 

186 yaml_path = base_path / self._load_yaml_path() 

187 if yaml_path.exists(): 

188 self._load_from_yaml(yaml_path) 

189 self._loaded = True 

190 

191 @abstractmethod 

192 def _create_info_from_yaml(self, uuid_data: dict[str, Any], uuid: BluetoothUUID) -> U: 

193 """Create info instance from YAML data. 

194 

195 Subclasses must implement to create the appropriate info type. 

196 """ 

197 

198 def get_info(self, identifier: str | BluetoothUUID) -> U | None: 

199 """Get info by UUID, name, ID, or alias. 

200 

201 Args: 

202 identifier: UUID string/int/BluetoothUUID, or name/ID/alias 

203 

204 Returns: 

205 Info if found, None otherwise 

206 """ 

207 self._ensure_loaded() 

208 with self._lock: 

209 # Handle BluetoothUUID directly 

210 if isinstance(identifier, BluetoothUUID): 

211 canonical_key = identifier.full_form 

212 return self._canonical_store.get(canonical_key) 

213 

214 # Normalize string identifier 

215 search_key = str(identifier).strip() 

216 

217 # Try UUID normalization first 

218 try: 

219 bt_uuid = BluetoothUUID(search_key) 

220 canonical_key = bt_uuid.full_form 

221 if canonical_key in self._canonical_store: 

222 return self._canonical_store[canonical_key] 

223 except ValueError: 

224 pass # UUID normalization failed, continue to alias lookup 

225 # Check alias index (normalized to lowercase) 

226 alias_key = self._alias_index.get(search_key.lower()) 

227 if alias_key and alias_key in self._canonical_store: 

228 return self._canonical_store[alias_key] 

229 

230 return None 

231 

232 def register_runtime_entry(self, entry: object) -> None: 

233 """Register a runtime UUID entry, preserving original SIG info if overridden. 

234 

235 Args: 

236 entry: Custom entry with uuid, name, id, etc. 

237 """ 

238 self._ensure_loaded() 

239 with self._lock: 

240 bt_uuid = getattr(entry, "uuid", None) 

241 if bt_uuid is None: 

242 raise ValueError("Entry must have a uuid attribute") 

243 bt_uuid = bt_uuid if isinstance(bt_uuid, BluetoothUUID) else BluetoothUUID(bt_uuid) 

244 canonical_key = bt_uuid.normalized 

245 

246 # Preserve original SIG info if we're overriding it 

247 # Assume entries in canonical_store that aren't in runtime_overrides are SIG entries 

248 if canonical_key in self._canonical_store and canonical_key not in self._runtime_overrides: 

249 self._runtime_overrides[canonical_key] = self._canonical_store[canonical_key] 

250 

251 # Create runtime info 

252 info = self._create_runtime_info(entry, bt_uuid) 

253 self._store_info(info) 

254 

255 @abstractmethod 

256 def _create_runtime_info(self, entry: object, uuid: BluetoothUUID) -> U: 

257 """Create runtime info from entry.""" 

258 

259 def remove_runtime_override(self, normalized_uuid: BluetoothUUID) -> None: 

260 """Remove runtime override, restoring original SIG info if available. 

261 

262 Args: 

263 normalized_uuid: UUID to remove override for 

264 """ 

265 self._ensure_loaded() 

266 with self._lock: 

267 # Restore original SIG info if we have it 

268 uuid_key = normalized_uuid.full_form 

269 if uuid_key in self._runtime_overrides: 

270 original_info = self._runtime_overrides.pop(uuid_key) 

271 self._store_info(original_info) 

272 elif uuid_key in self._canonical_store: 

273 # Remove runtime entry entirely 

274 # NOTE: Runtime tracking is registry-specific (e.g., uuid_registry uses _runtime_uuids set) 

275 del self._canonical_store[uuid_key] 

276 # Remove associated aliases 

277 aliases_to_remove = [alias for alias, key in self._alias_index.items() if key == uuid_key] 

278 for alias in aliases_to_remove: 

279 del self._alias_index[alias] 

280 

281 def list_registered(self) -> list[str]: 

282 """List all registered normalized UUIDs.""" 

283 self._ensure_loaded() 

284 with self._lock: 

285 return list(self._canonical_store.keys()) 

286 

287 def list_aliases(self, uuid: BluetoothUUID) -> list[str]: 

288 """List all aliases for a normalized UUID.""" 

289 self._ensure_loaded() 

290 with self._lock: 

291 return [alias for alias, key in self._alias_index.items() if key == uuid.normalized] 

292 

293 @classmethod 

294 def get_instance(cls: type[BU]) -> BU: 

295 """Get the singleton instance of the registry.""" 

296 if cls._instance is None: 

297 with cls._instance_lock: 

298 if cls._instance is None: 

299 cls._instance = cls() 

300 return cast("BU", cls._instance) 

301 

302 

303class BaseUUIDClassRegistry(RegistryMixin, ABC, Generic[E, C]): 

304 """Base class for UUID-based registries that store classes with enum-keyed access. 

305 

306 This registry type is designed for GATT characteristics and services that need: 

307 1. UUID → Class mapping (e.g., "2A19" → BatteryLevelCharacteristic class) 

308 2. Enum → Class mapping (e.g., CharacteristicName.BATTERY_LEVEL → BatteryLevelCharacteristic class) 

309 3. Runtime class registration with override protection 

310 4. Thread-safe singleton pattern with lazy loading 

311 

312 Unlike BaseUUIDRegistry which stores info objects (metadata), this stores actual classes 

313 that can be instantiated. 

314 

315 Subclasses should: 

316 1. Call super().__init__() in their __init__ 

317 2. Implement _load() to perform class discovery (must set self._loaded = True) 

318 3. Implement _build_enum_map() to create the enum → class mapping 

319 4. Implement _discover_sig_classes() to find built-in SIG classes 

320 5. Optionally override _allows_sig_override() for custom override rules 

321 """ 

322 

323 _instance: ClassVar[BaseUUIDClassRegistry[Any, Any] | None] = None 

324 _instance_lock: ClassVar[threading.RLock] = threading.RLock() 

325 

326 def __init__(self) -> None: 

327 """Initialize the class registry.""" 

328 self._lock = threading.RLock() 

329 self._loaded: bool = False 

330 self._custom_classes: dict[BluetoothUUID, type[C]] = {} 

331 self._sig_class_cache: dict[BluetoothUUID, type[C]] | None = None 

332 self._enum_map_cache: dict[E, type[C]] | None = None 

333 

334 @abstractmethod 

335 def _get_base_class(self) -> type[C]: 

336 """Return the base class that all registered classes must inherit from. 

337 

338 This is used for validation when registering custom classes. 

339 

340 Returns: 

341 The base class type 

342 """ 

343 

344 @abstractmethod 

345 def _build_enum_map(self) -> dict[E, type[C]]: 

346 """Build the enum → class mapping. 

347 

348 This should discover all SIG-defined classes and map them to their enum values. 

349 

350 Returns: 

351 Dictionary mapping enum members to class types 

352 """ 

353 

354 @abstractmethod 

355 def _discover_sig_classes(self) -> list[type[C]]: 

356 """Discover all SIG-defined classes in the package. 

357 

358 Returns: 

359 List of discovered class types 

360 """ 

361 

362 def _allows_sig_override(self, custom_cls: type[C], sig_cls: type[C]) -> bool: 

363 """Check if a custom class is allowed to override a SIG class. 

364 

365 Default implementation checks for _allows_sig_override attribute on the custom class. 

366 Subclasses can override for custom logic. 

367 

368 Args: 

369 custom_cls: The custom class attempting to override 

370 sig_cls: The existing SIG class being overridden (unused in default implementation) 

371 

372 Returns: 

373 True if override is allowed, False otherwise 

374 """ 

375 del sig_cls # Unused in default implementation 

376 return getattr(custom_cls, "_allows_sig_override", False) 

377 

378 def _get_enum_map(self) -> dict[E, type[C]]: 

379 """Get the cached enum → class mapping, building if necessary.""" 

380 if self._enum_map_cache is None: 

381 self._enum_map_cache = self._build_enum_map() 

382 return self._enum_map_cache 

383 

384 def _get_sig_classes_map(self) -> dict[BluetoothUUID, type[C]]: 

385 """Get the cached UUID → SIG class mapping, building if necessary.""" 

386 if self._sig_class_cache is None: 

387 self._sig_class_cache = {} 

388 for cls in self._discover_sig_classes(): 

389 try: 

390 uuid_obj = cls.get_class_uuid() # type: ignore[attr-defined] # Generic C bound lacks this method; runtime dispatch is correct 

391 if uuid_obj: 

392 self._sig_class_cache[uuid_obj] = cls 

393 except (AttributeError, ValueError): 

394 # Skip classes that can't resolve UUID 

395 continue 

396 return self._sig_class_cache 

397 

398 def register_class(self, uuid: str | BluetoothUUID | int, cls: type[C], override: bool = False) -> None: 

399 """Register a custom class at runtime. 

400 

401 Args: 

402 uuid: The UUID for this class (string, BluetoothUUID, or int) 

403 cls: The class to register 

404 override: Whether to override existing registrations 

405 

406 Raises: 

407 TypeError: If cls is not the correct type 

408 ValueError: If UUID conflicts with existing registration and override=False, 

409 or if attempting to override SIG class without permission 

410 """ 

411 self._ensure_loaded() 

412 

413 # Validate that cls is actually a subclass of the base class 

414 base_class = self._get_base_class() 

415 if not issubclass(cls, base_class): 

416 raise TypeError(f"Registered class must inherit from {base_class.__name__}, got {cls.__name__}") 

417 

418 # Normalize to BluetoothUUID 

419 bt_uuid = uuid if isinstance(uuid, BluetoothUUID) else BluetoothUUID(uuid) 

420 

421 # Check for SIG class collision 

422 sig_classes = self._get_sig_classes_map() 

423 sig_cls = sig_classes.get(bt_uuid) 

424 

425 with self._lock: 

426 # Check for custom class collision 

427 if not override and bt_uuid in self._custom_classes: 

428 raise ValueError(f"UUID {bt_uuid} already registered. Use override=True to replace.") 

429 

430 # If collides with SIG class, enforce override rules 

431 if sig_cls is not None: 

432 if not override: 

433 raise ValueError( 

434 f"UUID {bt_uuid} conflicts with existing SIG class {sig_cls.__name__}. " 

435 "Use override=True to replace." 

436 ) 

437 if not self._allows_sig_override(cls, sig_cls): 

438 raise ValueError( 

439 f"Override of SIG class {sig_cls.__name__} requires " 

440 f"_allows_sig_override=True on {cls.__name__}." 

441 ) 

442 

443 self._custom_classes[bt_uuid] = cls 

444 

445 def unregister_class(self, uuid: str | BluetoothUUID | int) -> None: 

446 """Unregister a custom class. 

447 

448 Args: 

449 uuid: The UUID to unregister (string, BluetoothUUID, or int) 

450 """ 

451 bt_uuid = uuid if isinstance(uuid, BluetoothUUID) else BluetoothUUID(uuid) 

452 with self._lock: 

453 self._custom_classes.pop(bt_uuid, None) 

454 

455 def get_class_by_uuid(self, uuid: str | BluetoothUUID | int) -> type[C] | None: 

456 """Get the class for a given UUID. 

457 

458 Checks custom classes first, then SIG classes. 

459 

460 Args: 

461 uuid: The UUID to look up (string, BluetoothUUID, or int) 

462 

463 Returns: 

464 The class if found, None otherwise 

465 """ 

466 self._ensure_loaded() 

467 

468 # Normalize to BluetoothUUID 

469 bt_uuid = uuid if isinstance(uuid, BluetoothUUID) else BluetoothUUID(uuid) 

470 

471 # Check custom classes first 

472 with self._lock: 

473 if custom_cls := self._custom_classes.get(bt_uuid): 

474 return custom_cls 

475 

476 # Check SIG classes 

477 return self._get_sig_classes_map().get(bt_uuid) 

478 

479 def get_class_by_enum(self, enum_member: E) -> type[C] | None: 

480 """Get the class for a given enum member. 

481 

482 Args: 

483 enum_member: The enum member to look up 

484 

485 Returns: 

486 The class if found, None otherwise 

487 """ 

488 self._ensure_loaded() 

489 return self._get_enum_map().get(enum_member) 

490 

491 def list_custom_uuids(self) -> list[BluetoothUUID]: 

492 """List all custom registered UUIDs. 

493 

494 Returns: 

495 List of UUIDs with custom class registrations 

496 """ 

497 with self._lock: 

498 return list(self._custom_classes.keys()) 

499 

500 def clear_enum_map_cache(self) -> None: 

501 """Clear the cached enum → class mapping. 

502 

503 Useful when classes are registered/unregistered at runtime. 

504 """ 

505 self._enum_map_cache = None 

506 self._sig_class_cache = None 

507 

508 @classmethod 

509 def get_instance(cls: type[BC]) -> BC: 

510 """Get the singleton instance of the registry.""" 

511 if cls._instance is None: 

512 with cls._instance_lock: 

513 if cls._instance is None: 

514 cls._instance = cls() 

515 return cast("BC", cls._instance)