Skip to content

Commit

Permalink
Add Reverse lookup (#43)
Browse files Browse the repository at this point in the history
Reverse lookup

---------

Co-authored-by: Vincent Davis <forklift@vdavis.net>
  • Loading branch information
vincentdavis and Vincent Davis authored Dec 13, 2023
1 parent 59465e8 commit 2b8cbd2
Show file tree
Hide file tree
Showing 3 changed files with 191 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ exclude_lines =
# Don't complain if non-runnable code isn't run:
if 0:
if __name__ == .__main__.:

# Assume if TYPE_CHECKING is covered
if TYPE_CHECKING:
121 changes: 121 additions & 0 deletions src/bluetooth_numbers/reverse_lookup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Reverse lookup class to find UUIDs by their description."""
from __future__ import annotations

from typing import TYPE_CHECKING, Literal, NamedTuple, Sequence

if TYPE_CHECKING:
from uuid import UUID

from bluetooth_numbers import characteristic, company, descriptor, oui, service

LOGIC = Literal["OR", "AND", "SUBSTR"]
UUID_TYPE_DEFAULT: Sequence[str] = (
"characteristic",
"company",
"descriptor",
"oui",
"service",
)


class Match(NamedTuple):
"""Named tuple to hold a UUID and its description."""

uuid: str | UUID | int
description: str
uuid_type: str


class ReverseLookup:
"""Reverse lookup class to find UUIDs by their description.
Examples:
>>> from bluetooth_numbers.reverse_lookup import ReverseLookup, Match
>>> rl = ReverseLookup()
>>> matches = rl.lookup("Cycling Power")
>>> Match('00:05:5A', 'Power Dsine Ltd.', 'oui') in matches
True
>>> rl.lookup("Cycling Power Feature",
... logic="AND") # doctest: +NORMALIZE_WHITESPACE
{Match(uuid=10853, description='Cycling Power Feature',
uuid_type='characteristic')}
>>> rl.lookup("Power Feature", uuid_types=['characteristic'],
... logic="SUBSTR") # doctest: +NORMALIZE_WHITESPACE
{Match(uuid=10853, description='Cycling Power Feature',
uuid_type='characteristic')}
"""

def __init__(self) -> None:
"""Initialize the ReverseLookup class, build index."""
self.index = self._build_index()

def _build_index(self) -> dict[str, set[Match]]:
"""Build dictionary (index) of terms to UUIDs.
Returns:
dict: dict[str, set[Match]] .
"""
reverse_lookup: dict[str, set[Match]] = {}
uuid_dicts = (
(characteristic, "characteristic"),
(company, "company"),
(descriptor, "descriptor"),
(oui, "oui"),
(service, "service"),
)
for uuid_dict, uuid_type in uuid_dicts:
for uuid, description in uuid_dict.items(): # type: ignore[attr-defined]
for term in description.lower().split(" "):
if term not in reverse_lookup:
reverse_lookup[term] = set()
reverse_lookup[term].add(Match(uuid, description, uuid_type))
return reverse_lookup

def lookup(
self,
terms: str,
uuid_types: Sequence[str] = UUID_TYPE_DEFAULT,
logic: LOGIC = "OR",
) -> set[Match]:
"""Return the UUIDs for a given term(s).
Args:
terms: String with the term(s) to search for.
uuid_types: Sequence of UUID types to search in.
logic: Search logic to use. Can be "OR", "AND" or "SUBSTR".
Returns:
set: set[Match]: Set of Match named tuples.
"""
terms_set: set[str] = set(terms.lower().split(" "))
results: set[Match] = set()
if logic == "OR":
"""For every term in the string add the UUIDs to the results set."""
for term in terms_set:
results.update(
m for m in self.index.get(term, set()) if m.uuid_type in uuid_types
)
elif logic == "AND":
"""Every term in the terms string must be in the description."""
for term in terms_set:
term_matches = {
m
for m in self.index.get(term, set())
if terms_set.issubset(m for m in m.description.lower().split(" "))
and m.uuid_type in uuid_types
}
if not results:
results.update(term_matches)
else:
results.intersection_update(term_matches)
elif logic == "SUBSTR":
"""The description must match the a substring of the description."""
lower_term_str = terms.lower()
for term in terms_set:
results.update(
m
for m in self.index.get(term, set())
if lower_term_str in m.description.lower()
and m.uuid_type in uuid_types
)
return results
67 changes: 67 additions & 0 deletions tests/test_reverse_lookup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Test the bluetooth_numbers.reverse_lookup module."""
import pytest

from bluetooth_numbers.reverse_lookup import Match, ReverseLookup


@pytest.fixture()
def reverse_lookup() -> ReverseLookup:
"""Return a ReverseLookup instance."""
return ReverseLookup()


def test_valid_reverse_lookup(reverse_lookup: ReverseLookup) -> None:
"""Test terms that should return a Match."""
assert Match(6168, "Cycling Power", "service") in reverse_lookup.lookup(
"Power",
logic="OR",
)
assert Match(6168, "Cycling Power", "service") in reverse_lookup.lookup(
"Power Cycling",
logic="AND",
)
assert Match(6168, "Cycling Power", "service") in reverse_lookup.lookup(
"Power",
logic="SUBSTR",
)
assert Match(6168, "Cycling Power", "service") in reverse_lookup.lookup(
"Cycling",
uuid_types=["service"],
logic="OR",
)


def test_bad_term_reverse_lookup(reverse_lookup: ReverseLookup) -> None:
"""Test terms that should return an empty set."""
assert reverse_lookup.lookup("foobar") == set()
assert reverse_lookup.lookup("foobar", logic="AND") == set()
assert Match(6168, "Cycling Power", "service") not in reverse_lookup.lookup(
"Power FooBar",
logic="AND",
)
assert reverse_lookup.lookup("foobar", logic="SUBSTR") == set()


def test_wrong_uuid_type_reverse_lookup(reverse_lookup: ReverseLookup) -> None:
"""Return an empty set when the term is not found in the uuid_type."""
assert Match(6168, "Cycling Power", "service") not in reverse_lookup.lookup(
"Cycling",
uuid_types=["descriptor"],
logic="OR",
)


def test_bad_valid_terms_reverse_lookup(reverse_lookup: ReverseLookup) -> None:
"""Test terms that should return an empty set."""
assert Match(6168, "Cycling Power", "service") in reverse_lookup.lookup(
"Power FooBar",
logic="OR",
)
assert Match(6168, "Cycling Power", "service") not in reverse_lookup.lookup(
"Power FooBar",
logic="AND",
)
assert Match(6168, "Cycling Power", "service") not in reverse_lookup.lookup(
"Power FooBar",
logic="SUBSTR",
)

0 comments on commit 2b8cbd2

Please sign in to comment.