Skip to content

Commit bad93c5

Browse files
committed
hid_parser: fix type hinting
Signed-off-by: Filipe Laíns <lains@riseup.net>
1 parent ae843cc commit bad93c5

3 files changed

Lines changed: 72 additions & 53 deletions

File tree

hid_parser/__init__.py

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import typing
99
import warnings
1010

11-
from collections.abc import Iterable, Iterator, Sequence
11+
from collections.abc import Collection, Iterable, Iterator, Sequence
1212
from typing import Any, Literal, Optional, TextIO
1313

1414
import hid_parser.data
@@ -233,20 +233,10 @@ def __repr__(self) -> str:
233233
return f'Usage(page={page_str}, usage={usage_str})'
234234

235235
@property
236-
def usage_types(self) -> tuple[hid_parser.data.UsageTypes]:
237-
subdata = hid_parser.data.UsagePages.get_subdata(self.page).get_subdata(self.usage)
238-
239-
if isinstance(subdata, tuple):
240-
types = subdata
241-
else:
242-
types = (subdata,)
243-
244-
for typ in types:
245-
if not isinstance(typ, hid_parser.data.UsageTypes):
246-
msg = f"Expecting usage type but got '{type(typ)}'"
247-
raise TypeError(msg)
248-
249-
return typing.cast(tuple[hid_parser.data.UsageTypes], types)
236+
def usage_types(self) -> Collection[hid_parser.data.UsageTypes]:
237+
usage_page = hid_parser.data.UsagePages.get_subdata(self.page)
238+
usage_types = usage_page.get_subdata(self.usage)
239+
return usage_types
250240

251241

252242
class UsageValue:
@@ -794,7 +784,8 @@ def _append_items(
794784
if len(usages) > report_count:
795785
report_count = len(usages)
796786
else:
797-
usages += [] * report_count - len(usages)
787+
missing_usage_count = report_count - len(usages)
788+
usages += [] * missing_usage_count
798789

799790
for usage in usages:
800791
item = VariableItem(
@@ -989,7 +980,7 @@ def print(self, level: int = 0, file: TextIO = sys.stdout) -> None: # noqa: C90
989980
def printl(string: str) -> None:
990981
print(' ' * level + string, file=file)
991982

992-
usage_data: Literal[False] | hid_parser.data._Data | None = False
983+
usage_page: Literal[False] | hid_parser.data.UsagePage | None = False
993984

994985
for typ, tag, data in self._iterate_raw():
995986
if typ == Type.MAIN:
@@ -1024,9 +1015,9 @@ def printl(string: str) -> None:
10241015
try:
10251016
printl(f'Usage Page ({hid_parser.data.UsagePages.get_description(data)})')
10261017
try:
1027-
usage_data = hid_parser.data.UsagePages.get_subdata(data)
1018+
usage_page = hid_parser.data.UsagePages.get_subdata(data)
10281019
except ValueError:
1029-
usage_data = None
1020+
usage_page = None
10301021
except KeyError:
10311022
printl(f'Usage Page (Unknown 0x{data:04x})')
10321023

@@ -1065,13 +1056,13 @@ def printl(string: str) -> None:
10651056

10661057
elif typ == Type.LOCAL:
10671058
if tag == TagLocal.USAGE:
1068-
if usage_data is False:
1059+
if usage_page is False:
10691060
msg = 'Usage field found but no usage page'
10701061
raise InvalidReportDescriptor(msg)
10711062

1072-
if usage_data:
1063+
if usage_page:
10731064
try:
1074-
printl(f'Usage ({usage_data.get_description(data)})')
1065+
printl(f'Usage ({usage_page.get_description(data)})')
10751066
except KeyError:
10761067
printl(f'Usage (Unknown, 0x{data:04x})')
10771068
else:

hid_parser/data.py

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
11
# SPDX-License-Identifier: MIT
22

33
import enum
4+
import sys
45

5-
from typing import Any, ClassVar, Optional
6+
from collections.abc import Collection
7+
from typing import Any, Generic, Optional
8+
9+
10+
if sys.version_info >= (3, 13):
11+
from typing import TypeVar
12+
else:
13+
from typing_extensions import TypeVar
14+
15+
16+
if sys.version_info >= (3, 10):
17+
from types import NoneType
18+
else:
19+
NoneType = type(None)
620

721

822
class _DataMeta(type):
@@ -42,15 +56,15 @@ class _DataMeta(type):
4256
This metaclass also does some verification to prevent duplicated data.
4357
"""
4458

45-
def __new__(mcs, name: str, bases: tuple[Any], dic: dict[str, Any]): # type: ignore[no-untyped-def] # noqa: C901
46-
dic['_single'] = {}
47-
dic['_range'] = []
59+
def __new__(cls, name: str, bases: tuple[Any], obj_dict: dict[str, Any]): # type: ignore[no-untyped-def] # noqa: C901
60+
obj_dict['_single'] = {}
61+
obj_dict['_range'] = []
4862

49-
# allow constructing data via a data dictionary as opposed to directly in the object body
50-
if 'data' in dic:
51-
data = dic.pop('data')
63+
# allow constructing data via a data obj_dicttionary as opposed to directly in the object body
64+
if 'data' in obj_dict:
65+
data = obj_dict.pop('data')
5266
else:
53-
data = dic
67+
data = obj_dict
5468

5569
for attr in data:
5670
if not attr.startswith('_') and isinstance(data[attr], tuple):
@@ -67,17 +81,17 @@ def __new__(mcs, name: str, bases: tuple[Any], dic: dict[str, Any]): # type: ig
6781
msg = f"Second element of '{attr}' should be a string"
6882
raise TypeError(msg)
6983

70-
if num in dic['_single']:
84+
if num in obj_dict['_single']:
7185
msg = f"Duplicated value in '{attr}' ({num})"
7286
raise ValueError(msg)
7387

74-
for nmin, nmax, _ in dic['_range']:
88+
for nmin, nmax, _ in obj_dict['_range']:
7589
if nmin <= num <= nmax:
7690
msg = f"Duplicated value in '{attr}' ({num})"
7791
raise ValueError(msg)
7892

79-
dic[attr] = num
80-
dic['_single'][num] = desc, sub
93+
obj_dict[attr] = num
94+
obj_dict['_single'][num] = desc, sub
8195
elif len(data[attr]) == 5: # range
8296
nmin, el, nmax, desc, sub = data[attr]
8397

@@ -94,33 +108,36 @@ def __new__(mcs, name: str, bases: tuple[Any], dic: dict[str, Any]): # type: ig
94108
msg = f"Fourth element of '{attr}' should be a string"
95109
raise TypeError(msg)
96110

97-
for num in dic['_single']:
111+
for num in obj_dict['_single']:
98112
if nmin <= num <= nmax:
99113
msg = f"Duplicated value in '{attr}' ({num})"
100114
raise ValueError(msg)
101115

102-
dic[attr] = range(nmin, nmax + 1)
103-
dic['_range'].append((nmin, nmax, (desc, sub)))
116+
obj_dict[attr] = range(nmin, nmax + 1)
117+
obj_dict['_range'].append((nmin, nmax, (desc, sub)))
104118

105119
else:
106120
msg = f'Invalid field: {attr}'
107121
raise ValueError(msg)
108122

109-
return super().__new__(mcs, name, bases, dic)
123+
return super().__new__(cls, name, bases, obj_dict)
110124

111125

112-
class _Data(metaclass=_DataMeta):
126+
_SubdataType = TypeVar('_SubdataType', default=NoneType)
127+
128+
129+
class _Data(Generic[_SubdataType], metaclass=_DataMeta):
113130
"""
114131
This class provides a get_description method to get data out of _single and _range.
115132
See the _DataMeta documentation for more information.
116133
"""
117134

118-
_DATA = tuple[str, Optional[Any]]
119-
_single: dict[int, _DATA]
120-
_range: list[tuple[int, int, _DATA]]
135+
_single: dict[int, tuple[str, _SubdataType]]
136+
_range: list[tuple[int, int, tuple[str, _SubdataType]]]
137+
data: dict[str, tuple[int, str, _SubdataType]]
121138

122139
@classmethod
123-
def _get_data(cls, num: Optional[int]) -> _DATA:
140+
def _get_data(cls, num: Optional[int]) -> tuple[str, _SubdataType]:
124141
if num is None:
125142
msg = 'Data index is not an int'
126143
raise KeyError(msg)
@@ -140,7 +157,7 @@ def get_description(cls, num: Optional[int]) -> str:
140157
return cls._get_data(num)[0]
141158

142159
@classmethod
143-
def get_subdata(cls, num: Optional[int]) -> Any:
160+
def get_subdata(cls, num: Optional[int]) -> _SubdataType:
144161
subdata = cls._get_data(num)[1]
145162

146163
if not subdata:
@@ -210,7 +227,11 @@ class Collections(_Data):
210227
VENDOR = 0x80, ..., 0xFF, 'Vendor'
211228

212229

213-
class GenericDesktopControls(_Data):
230+
class UsagePage(_Data[Collection[UsageTypes]]):
231+
pass
232+
233+
234+
class GenericDesktopControls(UsagePage):
214235
POINTER = 0x01, 'Pointer', UsageTypes.CP
215236
MOUSE = 0x02, 'Mouse', UsageTypes.CA
216237
JOYSTICK = 0x04, 'Joystick', UsageTypes.CA
@@ -282,7 +303,7 @@ class GenericDesktopControls(_Data):
282303
SYSTEM_DISPLAY_LCD_AUTOSCALE = 0xB7, 'System Display LCD Autoscale', UsageTypes.OSC
283304

284305

285-
class KeyboardKeypad(_Data):
306+
class KeyboardKeypad(UsagePage):
286307
NO_EVENT = 0x00, 'No event indicated', UsageTypes.SEL
287308
KEYBOARD_ERROR_ROLL_OVER = 0x01, 'Keyboard ErrorRollOver', UsageTypes.SEL
288309
KEYBOARD_POST = 0x02, 'Keyboard POSTFail', UsageTypes.SEL
@@ -504,7 +525,7 @@ class KeyboardKeypad(_Data):
504525
KEYBOARD_RIGHT_GUI = 0xE7, 'Keyboard Right GUI', UsageTypes.DV
505526

506527

507-
class Led(_Data):
528+
class Led(UsagePage):
508529
NUM_LOCK = 0x01, 'Num Lock', UsageTypes.OOC
509530
CAPS_LOCK = 0x02, 'Caps Lock', UsageTypes.OOC
510531
SCROLL_LOCK = 0x03, 'Scroll Lock', UsageTypes.OOC
@@ -584,15 +605,19 @@ class Led(_Data):
584605
EXTERNAL_POWER_CONNECTED = 0x4D, 'External Power Connected', UsageTypes.OOC
585606

586607

587-
class Button(_Data):
608+
class Button(UsagePage):
588609
_USAGE_TYPES = (
589610
UsageTypes.SEL,
590611
UsageTypes.OOC,
591612
UsageTypes.MC,
592613
UsageTypes.OSC,
593614
)
594615

595-
data: ClassVar[dict[str, tuple[int, str, UsageTypes]]] = {
616+
# XXX: The type of the 'data' variable includes a generic type bound to the
617+
# class, which currently isn't supported in ClassVar, so we don't
618+
# define 'data' as a ClassVar. Ruff complains about it in RUF012, but
619+
# since we can't define `data` as ClassVar, let's just ignore it.
620+
data = { # noqa: RUF012
596621
'NO_BUTTON': (0x0000, 'Button 1 (primary/trigger)', _USAGE_TYPES),
597622
'BUTTON_1': (0x0001, 'Button 1 (primary/trigger)', _USAGE_TYPES),
598623
'BUTTON_2': (0x0002, 'Button 2 (secondary)', _USAGE_TYPES),
@@ -603,7 +628,7 @@ class Button(_Data):
603628
data[f'BUTTON_{_i}'] = _i, f'Button {_i}', _USAGE_TYPES
604629

605630

606-
class Consumer(_Data):
631+
class Consumer(UsagePage):
607632
CONSUMER_CONTROL = 0x0001, 'Consumer Control', UsageTypes.CA
608633
NUMERIC_KEY_PAD = 0x0002, 'Numeric Key Pad', UsageTypes.NARY
609634
PROGRAMMABLE_BUTTONS = 0x0003, 'Programmable Buttons', UsageTypes.NARY
@@ -970,7 +995,7 @@ class Consumer(_Data):
970995
AC_DISTRIBUTE_VERTICALLY = 0x029C, 'AC Distribute Vertically', UsageTypes.SEL
971996

972997

973-
class PowerDevice(_Data):
998+
class PowerDevice(UsagePage):
974999
INAME = 0x01, 'iName', UsageTypes.SV
9751000
PRESENT_STATUS = 0x02, 'PresentStatus', UsageTypes.CL
9761001
CHARGED_STATUS = 0x03, 'ChangedStatus', UsageTypes.CL
@@ -1050,13 +1075,13 @@ class PowerDevice(_Data):
10501075
ISERIALNUMBER = 0xFF, 'iSerialNumber', UsageTypes.SV
10511076

10521077

1053-
class FIDO(_Data):
1078+
class FIDO(UsagePage):
10541079
U2F_AUTHENTICATOR_DEVICEM = 0x01, 'U2F Authenticator Device'
10551080
INPUT_REPORT_DATA = 0x20, 'Input Report Data'
10561081
OUTPUT_REPORT_DATA = 0x21, 'Output Report Data'
10571082

10581083

1059-
class UsagePages(_Data):
1084+
class UsagePages(_Data[UsagePage]):
10601085
GENERIC_DESKTOP_CONTROLS_PAGE = 0x01, 'Generic Desktop Controls', GenericDesktopControls
10611086
SIMULATION_CONTROLS_PAGE = 0x02, 'Simulation Controls'
10621087
VR_CONTROLS_PAGE = 0x03, 'VR Controls'

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ classifiers = [
2626
urls.homepage = 'https://github.com/usb-tools/python-hid-parser'
2727
urls.issues = 'https://github.com/usb-tools/python-hid-parser/issues'
2828
urls.source = 'https://github.com/usb-tools/python-hid-parser'
29+
dependencies = [
30+
'typing_extension >= 4.4.0; python_version < "3.13"'
31+
]
2932

3033
[dependency-groups]
3134
test = [

0 commit comments

Comments
 (0)