Skip to content

bleak: cache BleakAdapter instances per event loop#1981

Open
Vodur wants to merge 1 commit into
hbldh:developfrom
Vodur:bleak-adapter-singleton
Open

bleak: cache BleakAdapter instances per event loop#1981
Vodur wants to merge 1 commit into
hbldh:developfrom
Vodur:bleak-adapter-singleton

Conversation

@Vodur
Copy link
Copy Markdown
Contributor

@Vodur Vodur commented May 5, 2026

BleakAdapter.get() now returns the same BleakAdapter instance on repeated calls from the same event loop with the same adapter selection. The cache is keyed by (loop, PlatformBleakAdapter, adapter_path), so different adapters selected via bluez={"adapter": ...} get distinct instances.

Mirrors the pattern in bleak.backends.bluezdbus.manager._global_instances introduced in 1be914c.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 5, 2026

Codecov Report

❌ Patch coverage is 87.50000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.62%. Comparing base (8ba2718) to head (96a44a7).
⚠️ Report is 4 commits behind head on develop.

Files with missing lines Patch % Lines
bleak/__init__.py 86.66% 1 Missing and 1 partial ⚠️
bleak/backends/adapter.py 75.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1981      +/-   ##
===========================================
+ Coverage    52.06%   52.62%   +0.55%     
===========================================
  Files           43       43              
  Lines         4091     4118      +27     
  Branches       503      505       +2     
===========================================
+ Hits          2130     2167      +37     
+ Misses        1830     1819      -11     
- Partials       131      132       +1     
Flag Coverage Δ
bluez-integration-py3.10 ?
bluez-integration-py3.11 ?
bluez-integration-py3.12 ?
bluez-integration-py3.13 ?
bluez-integration-py3.14 ?
bluez-integration-py310 39.38% <87.50%> (?)
bluez-integration-py311 39.38% <87.50%> (?)
bluez-integration-py312 39.38% <87.50%> (?)
bluez-integration-py313 39.38% <87.50%> (?)
bluez-integration-py314 37.89% <87.50%> (?)
macos-latest-py3.10 ?
macos-latest-py3.11 ?
macos-latest-py3.12 ?
macos-latest-py3.13 ?
macos-latest-py3.14 ?
macos-latest-py310 20.05% <29.16%> (?)
macos-latest-py311 20.05% <29.16%> (?)
macos-latest-py312 20.05% <29.16%> (?)
macos-latest-py313 20.05% <29.16%> (?)
macos-latest-py314 19.87% <29.16%> (?)
ubuntu-latest-py3.10 ?
ubuntu-latest-py3.11 ?
ubuntu-latest-py3.12 ?
ubuntu-latest-py3.13 ?
ubuntu-latest-py3.14 ?
ubuntu-latest-py310 24.04% <45.83%> (?)
ubuntu-latest-py311 24.04% <45.83%> (?)
ubuntu-latest-py312 24.04% <45.83%> (?)
ubuntu-latest-py313 24.04% <45.83%> (?)
ubuntu-latest-py314 22.16% <45.83%> (?)
windows-latest-py3.10 ?
windows-latest-py3.11 ?
windows-latest-py3.12 ?
windows-latest-py3.13 ?
windows-latest-py3.14 ?
windows-latest-py310 18.74% <29.16%> (?)
windows-latest-py311 18.74% <29.16%> (?)
windows-latest-py312 18.74% <29.16%> (?)
windows-latest-py313 18.74% <29.16%> (?)
windows-latest-py314 18.47% <29.16%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Comment thread bleak/__init__.py Outdated
)
return cls(await PlatformBleakAdapter.get(bluez=bluez))
loop = asyncio.get_running_loop()
adapter_path = bluez.get("adapter")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't going to work cross-platform. It needs to only be called if PlatformBleakAdapter is the BlueZ backend type. And we can't import that type here, otherwise it would crash on other platforms.

In practice, I guess it probably won't cause problems if we leave it like this, but logically, it doesn't seem quite right.

Ideally, we would move the backend-specific stuff into the backend itself. But I don't have any good ideas on how to do that without repeating code in each backend.

A hacky workaround could be to use if PlatformBleakAdapter.__name__ == "BleakAdapterBlueZDBus":.

Comment thread tests/test_adapter_singleton.py Outdated
async def test_get_returns_different_instance_per_bluez_adapter():
a = await BleakAdapter.get(backend=_FakeAdapter, bluez={"adapter": "hci0"})
b = await BleakAdapter.get(backend=_FakeAdapter, bluez={"adapter": "hci1"})
assert a is not b
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned above. Logically, this test should only pass on Linux and fail on other platforms.

Comment thread tests/test_adapter_singleton.py Outdated
from bleak.backends.adapter import BaseBleakAdapter


class _FakeAdapter(BaseBleakAdapter):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why using fake adapter? It means we are testing less real code.

Comment thread tests/test_adapter_singleton.py Outdated


@pytest.fixture(autouse=True)
def _clear_cache_and_counters(): # pyright: ignore[reportUnusedFunction]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _clear_cache_and_counters(): # pyright: ignore[reportUnusedFunction]
def clear_cache_and_counters():

Drop the _ to avoid needing to ignore lint errors.

Comment thread tests/test_adapter_singleton.py Outdated

@pytest.fixture(autouse=True)
def _clear_cache_and_counters(): # pyright: ignore[reportUnusedFunction]
BleakAdapter._instances.clear() # pyright: ignore[reportPrivateUsage]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each test should have a new event loop, so I'm not sure we actually need to do this.

Comment thread tests/test_adapter_singleton.py Outdated
assert a is not b


def test_separate_event_loops_get_separate_instances():
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in another comment, pytest should already be providing a new loop for ever test, so this test should not be necessary.

Comment thread tests/test_adapter_singleton.py Outdated
assert first is not second


def test_closed_loop_entries_are_pruned_lazily():
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to see in code coverage if this is working or not because the del line is either called or not.

Not sure that this test adds much on top of that.

Comment thread CHANGELOG.rst Outdated

Changed
-------
* ``BleakAdapter.get()`` now returns the same ``BleakAdapter`` instance on repeated calls from the same event loop with the same adapter selection.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We haven't done a release with the previous behavior, so we don't need a changelog entry in this case.

@Vodur Vodur force-pushed the bleak-adapter-singleton branch 2 times, most recently from dcceced to 7fde0a3 Compare May 5, 2026 16:59
@Vodur
Copy link
Copy Markdown
Contributor Author

Vodur commented May 5, 2026

Three CI failures from trying real BleakAdapter.get():

  • macOS (build_desktop): BleakBluetoothNotAvailableError: Bluetooth is unsupported - runner has no BT hardware
  • Linux (build_desktop): BleakDBusError: org.bluez was not provided by any .service files - bluetoothd not installed
  • Linux Alpine VM (integration_tests_bluez): BleakBluetoothNotAvailableError: No Bluetooth adapters found - VHCI is per-fixture, not active when our tests run

Real BleakAdapter.get() only works inside integration tests that have already set up their BT environment. So I switched to fake BaseBleakAdapter subclasses for unit tests. Real platform behavior is exercised by tests/integration/test_adapter.py.

Also added the cache_key classmethod (default None, BlueZ overrides to bluez["adapter"]) instead of the name-string check.

Force-pushed 7fde0a3.

@dlech
Copy link
Copy Markdown
Collaborator

dlech commented May 5, 2026

I don't understand why we can't put the tests in the integration tests and avoid mocking the BaseBleakAdapter.

@dlech
Copy link
Copy Markdown
Collaborator

dlech commented May 5, 2026

I don't understand why we can't put the tests in the integration tests and avoid mocking the BaseBleakAdapter.

Other than the special case of BlueZ with more than one adapter being used by Bleak at the same time.

@Vodur Vodur force-pushed the bleak-adapter-singleton branch from 7fde0a3 to e8f3dae Compare May 6, 2026 04:30
@Vodur
Copy link
Copy Markdown
Contributor Author

Vodur commented May 6, 2026

Moved both tests to tests/integration/test_adapter.py using the existing hci_transport fixture - no fakes, real BleakAdapter.get(). The differentiation test for the multi-adapter case doesn't need two VHCIs: BleakAdapterBlueZDBus.get() doesn't validate the adapter name (just builds the D-Bus path string), so two arbitrary strings via bluez={"adapter": ...} are enough to verify the cache-key dispatch.

@Vodur Vodur force-pushed the bleak-adapter-singleton branch from e8f3dae to da4920c Compare May 6, 2026 10:01
@dlech
Copy link
Copy Markdown
Collaborator

dlech commented May 6, 2026

so two arbitrary strings via bluez={"adapter": ...} are enough to verify the cache-key dispatch.

I would expect calling BleakAdapter.get(bluez={"adapter": "junk"}) should fail with an error that says that the adapter does not exist. So how could that test work? (I didn't look at the code yet.)

@Vodur
Copy link
Copy Markdown
Contributor Author

Vodur commented May 6, 2026

so two arbitrary strings via bluez={"adapter": ...} are enough to verify the cache-key dispatch.

I would expect calling BleakAdapter.get(bluez={"adapter": "junk"}) should fail with an error that says that the adapter does not exist. So how could that test work? (I didn't look at the code yet.)

Right - currently BleakAdapterBlueZDBus.get() doesn't validate the adapter name (you noted on #1979 that we'd defer fixing it). The test passes because of that, not because cache-key dispatch is being meaningfully exercised.

Easiest path: drop the differentiation test from this PR.

@Vodur Vodur force-pushed the bleak-adapter-singleton branch from da4920c to 0d007b6 Compare May 6, 2026 14:56
@classmethod
@override
def cache_key(cls, *, bluez: BlueZAdapterArgs = {}, **kwargs: Any) -> Optional[str]:
return bluez.get("adapter")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this works in all cases.

Consider the following on a system with two Bluetooth adapters:

  • Someone calls BleakAdapter.get() which gets the "best" adapter. cache_key is None because no extra args were passed.
  • Something changes that affects the "best" adapter algorithm (one adapter is powered down, USB adapter is removed, etc.)
  • Now, calling BleakAdapter.get() will return an adapter that might not be there any more rather than the "best" adapter.

It hasn't really come up before of what the expected behavior should be when this sort of thing happens. Maybe it is OK the like this for now.

Comment thread bleak/backends/bluezdbus/adapter.py Outdated

@classmethod
@override
def cache_key(cls, *, bluez: BlueZAdapterArgs = {}, **kwargs: Any) -> Optional[str]:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def cache_key(cls, *, bluez: BlueZAdapterArgs = {}, **kwargs: Any) -> Optional[str]:
def cache_key(cls, *, bluez: BlueZAdapterArgs = {}, **kwargs: Any) -> Hashable:

Needs to match signature we are overriding.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or replace Hashable with str | None everywhere in this patch.

Comment thread bleak/backends/adapter.py Outdated
raise NotImplementedError("get is not implemented for this backend")

@classmethod
def cache_key(cls, **kwargs: Any) -> Optional[Hashable]:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def cache_key(cls, **kwargs: Any) -> Optional[Hashable]:
def cache_key(cls, **kwargs: Any) -> Hashable:

None is hashable and isn't special in this case.

Comment thread bleak/__init__.py Outdated
tuple[
asyncio.AbstractEventLoop,
type[BaseBleakAdapter],
Optional[Hashable],
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Optional[Hashable],
Hashable,

@Vodur Vodur force-pushed the bleak-adapter-singleton branch from 0d007b6 to 68093f7 Compare May 9, 2026 18:36
Comment thread bleak/__init__.py Outdated
Comment thread tests/integration/test_adapter.py Outdated
BleakAdapter.get() now returns the same BleakAdapter instance on repeated calls from the same event loop with the same adapter selection. The cache is keyed by (loop, PlatformBleakAdapter, cache_key), where cache_key is supplied by each backend (BlueZ uses bluez['adapter'] to differentiate; other backends return None for a single cache entry per loop).

Mirrors the pattern in bleak.backends.bluezdbus.manager._global_instances introduced in 1be914c.
@Vodur
Copy link
Copy Markdown
Contributor Author

Vodur commented May 10, 2026

Tried @final first but pyright doesn't narrow self to BleakAdapter even on a final class, so the cast was still needed. Went with changing the return type from self to "BleakAdapter" since the class is final - but that's a public-signature change you didn't actually ask for.

@Vodur Vodur force-pushed the bleak-adapter-singleton branch from 68093f7 to 96a44a7 Compare May 10, 2026 04:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants