python-usb 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_usb/__init__.py +3 -0
- python_usb/cli.py +62 -0
- python_usb/device.py +31 -0
- python_usb/discover.py +570 -0
- python_usb/tree.py +80 -0
- python_usb-0.1.0.dist-info/METADATA +134 -0
- python_usb-0.1.0.dist-info/RECORD +11 -0
- python_usb-0.1.0.dist-info/WHEEL +5 -0
- python_usb-0.1.0.dist-info/entry_points.txt +2 -0
- python_usb-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_usb-0.1.0.dist-info/top_level.txt +1 -0
python_usb/__init__.py
ADDED
python_usb/cli.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""CLI entry point for python-usb."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .device import USBDevice
|
|
12
|
+
from .discover import discover
|
|
13
|
+
from .tree import render_tree
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> None:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="python-usb",
|
|
19
|
+
description="List USB devices in a developer-friendly tree view.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"-V", "--version",
|
|
23
|
+
action="version",
|
|
24
|
+
version=f"%(prog)s {__version__}",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"-j", "--json",
|
|
28
|
+
action="store_true",
|
|
29
|
+
help="output as JSON instead of tree view",
|
|
30
|
+
)
|
|
31
|
+
parser.parse_args(argv)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
devices = discover()
|
|
35
|
+
except RuntimeError as exc:
|
|
36
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
args = parser.parse_args(argv)
|
|
40
|
+
if args.json:
|
|
41
|
+
print(json.dumps(_devices_to_dicts(devices), indent=2))
|
|
42
|
+
else:
|
|
43
|
+
render_tree(devices)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _devices_to_dicts(devices: List[USBDevice]) -> list:
|
|
47
|
+
return [_dev_dict(d) for d in devices]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _dev_dict(dev: USBDevice) -> dict:
|
|
51
|
+
d: dict = {"name": dev.name}
|
|
52
|
+
for attr in (
|
|
53
|
+
"vendor_id", "product_id", "vendor_name", "product_name",
|
|
54
|
+
"serial", "speed", "bus", "address", "device_class",
|
|
55
|
+
"driver", "max_power", "version", "port",
|
|
56
|
+
):
|
|
57
|
+
val = getattr(dev, attr)
|
|
58
|
+
if val is not None:
|
|
59
|
+
d[attr] = val
|
|
60
|
+
if dev.children:
|
|
61
|
+
d["children"] = _devices_to_dicts(dev.children)
|
|
62
|
+
return d
|
python_usb/device.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""USB device data model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class USBDevice:
|
|
11
|
+
name: str
|
|
12
|
+
vendor_id: Optional[str] = None
|
|
13
|
+
product_id: Optional[str] = None
|
|
14
|
+
vendor_name: Optional[str] = None
|
|
15
|
+
product_name: Optional[str] = None
|
|
16
|
+
serial: Optional[str] = None
|
|
17
|
+
speed: Optional[str] = None
|
|
18
|
+
bus: Optional[str] = None
|
|
19
|
+
address: Optional[str] = None
|
|
20
|
+
device_class: Optional[str] = None
|
|
21
|
+
driver: Optional[str] = None
|
|
22
|
+
max_power: Optional[str] = None
|
|
23
|
+
version: Optional[str] = None
|
|
24
|
+
port: Optional[str] = None
|
|
25
|
+
children: List[USBDevice] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def id_pair(self) -> str:
|
|
29
|
+
if self.vendor_id and self.product_id:
|
|
30
|
+
return f"{self.vendor_id}:{self.product_id}"
|
|
31
|
+
return ""
|
python_usb/discover.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""Cross-platform USB device discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
from .device import USBDevice
|
|
15
|
+
|
|
16
|
+
_USB_CLASSES = {
|
|
17
|
+
"00": "Per-interface",
|
|
18
|
+
"01": "Audio",
|
|
19
|
+
"02": "CDC-Comm",
|
|
20
|
+
"03": "HID",
|
|
21
|
+
"05": "Physical",
|
|
22
|
+
"06": "Image",
|
|
23
|
+
"07": "Printer",
|
|
24
|
+
"08": "Mass Storage",
|
|
25
|
+
"09": "Hub",
|
|
26
|
+
"0a": "CDC-Data",
|
|
27
|
+
"0b": "Smart Card",
|
|
28
|
+
"0d": "Content Security",
|
|
29
|
+
"0e": "Video",
|
|
30
|
+
"0f": "Personal Healthcare",
|
|
31
|
+
"10": "Audio/Video",
|
|
32
|
+
"11": "Billboard",
|
|
33
|
+
"12": "USB-C Bridge",
|
|
34
|
+
"dc": "Diagnostic",
|
|
35
|
+
"e0": "Wireless",
|
|
36
|
+
"ef": "Miscellaneous",
|
|
37
|
+
"fe": "Application Specific",
|
|
38
|
+
"ff": "Vendor Specific",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_IOREG_DEVICE_CLASSES = {"IOUSBHostDevice", "IOUSBDevice"}
|
|
42
|
+
_IOREG_NODE_RE = re.compile(
|
|
43
|
+
r"^(?P<prefix>(?:\| |\s{2})*)\+-o (?P<name>.+?) <class (?P<class_name>[^,>]+)"
|
|
44
|
+
)
|
|
45
|
+
_IOREG_PROPERTY_RE = re.compile(r'"(?P<key>[^"]+)"\s*=\s*(?P<value>.+)$')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class _MacOSIONode:
|
|
50
|
+
name: str
|
|
51
|
+
class_name: str
|
|
52
|
+
properties: dict[str, object] = field(default_factory=dict)
|
|
53
|
+
children: list[_MacOSIONode] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def discover() -> List[USBDevice]:
|
|
57
|
+
"""Return USB device tree for the current platform."""
|
|
58
|
+
system = platform.system()
|
|
59
|
+
if system == "Darwin":
|
|
60
|
+
return _discover_macos()
|
|
61
|
+
elif system == "Linux":
|
|
62
|
+
return _discover_linux()
|
|
63
|
+
elif system == "Windows":
|
|
64
|
+
return _discover_windows()
|
|
65
|
+
else:
|
|
66
|
+
raise RuntimeError(f"Unsupported platform: {system}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# macOS
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _discover_macos() -> List[USBDevice]:
|
|
74
|
+
devices = _discover_macos_system_profiler()
|
|
75
|
+
if devices:
|
|
76
|
+
return devices
|
|
77
|
+
return _discover_macos_ioreg()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _discover_macos_system_profiler() -> List[USBDevice]:
|
|
81
|
+
try:
|
|
82
|
+
raw = subprocess.check_output(
|
|
83
|
+
["system_profiler", "SPUSBDataType", "-json"],
|
|
84
|
+
stderr=subprocess.DEVNULL,
|
|
85
|
+
timeout=10,
|
|
86
|
+
)
|
|
87
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
88
|
+
return []
|
|
89
|
+
data = json.loads(raw)
|
|
90
|
+
items = data.get("SPUSBDataType", [])
|
|
91
|
+
return [dev for item in items for dev in _parse_macos_item(item)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _discover_macos_ioreg() -> List[USBDevice]:
|
|
95
|
+
try:
|
|
96
|
+
raw = subprocess.check_output(
|
|
97
|
+
["ioreg", "-p", "IOUSB", "-w0", "-l"],
|
|
98
|
+
stderr=subprocess.DEVNULL,
|
|
99
|
+
timeout=10,
|
|
100
|
+
)
|
|
101
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
102
|
+
return []
|
|
103
|
+
return _parse_macos_ioreg(raw.decode(errors="replace").splitlines())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_macos_item(item: dict) -> List[USBDevice]:
|
|
107
|
+
devices: List[USBDevice] = []
|
|
108
|
+
if "_items" in item:
|
|
109
|
+
for child in item["_items"]:
|
|
110
|
+
devices.extend(_parse_macos_entry(child))
|
|
111
|
+
else:
|
|
112
|
+
devices.extend(_parse_macos_entry(item))
|
|
113
|
+
return devices
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_macos_entry(entry: dict) -> List[USBDevice]:
|
|
117
|
+
vid_pid = entry.get("vendor_id", ""), entry.get("product_id", "")
|
|
118
|
+
|
|
119
|
+
vid_raw = entry.get("vendor_id", "") or ""
|
|
120
|
+
pid_raw = entry.get("product_id", "") or ""
|
|
121
|
+
vid = _extract_hex(vid_raw)
|
|
122
|
+
pid = _extract_hex(pid_raw)
|
|
123
|
+
|
|
124
|
+
speed_raw = entry.get("device_speed", "")
|
|
125
|
+
speed = _normalise_speed(speed_raw)
|
|
126
|
+
|
|
127
|
+
dev = USBDevice(
|
|
128
|
+
name=entry.get("_name", "Unknown"),
|
|
129
|
+
vendor_id=vid,
|
|
130
|
+
product_id=pid,
|
|
131
|
+
vendor_name=entry.get("manufacturer", None),
|
|
132
|
+
serial=entry.get("serial_num", None),
|
|
133
|
+
speed=speed,
|
|
134
|
+
bus=entry.get("location_id", "").split("/")[0] if entry.get("location_id") else None,
|
|
135
|
+
max_power=entry.get("bus_power_used", entry.get("bus_power", None)),
|
|
136
|
+
device_class=None,
|
|
137
|
+
)
|
|
138
|
+
if "_items" in entry:
|
|
139
|
+
for child in entry["_items"]:
|
|
140
|
+
dev.children.extend(_parse_macos_entry(child))
|
|
141
|
+
return [dev]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _parse_macos_ioreg(lines: List[str]) -> List[USBDevice]:
|
|
145
|
+
roots: list[_MacOSIONode] = []
|
|
146
|
+
stack: list[_MacOSIONode] = []
|
|
147
|
+
current_node: Optional[_MacOSIONode] = None
|
|
148
|
+
in_properties = False
|
|
149
|
+
|
|
150
|
+
for line in lines:
|
|
151
|
+
node_match = _IOREG_NODE_RE.match(line)
|
|
152
|
+
if node_match:
|
|
153
|
+
depth = len(node_match.group("prefix")) // 2
|
|
154
|
+
node = _MacOSIONode(
|
|
155
|
+
name=_strip_ioreg_location(node_match.group("name").strip()),
|
|
156
|
+
class_name=node_match.group("class_name"),
|
|
157
|
+
)
|
|
158
|
+
stack = stack[:depth]
|
|
159
|
+
if stack:
|
|
160
|
+
stack[-1].children.append(node)
|
|
161
|
+
else:
|
|
162
|
+
roots.append(node)
|
|
163
|
+
stack.append(node)
|
|
164
|
+
current_node = node
|
|
165
|
+
in_properties = False
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
if current_node is None:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
stripped = line.strip()
|
|
172
|
+
block_marker = stripped.replace("|", "").strip()
|
|
173
|
+
if block_marker == "{":
|
|
174
|
+
in_properties = True
|
|
175
|
+
continue
|
|
176
|
+
if block_marker == "}":
|
|
177
|
+
in_properties = False
|
|
178
|
+
continue
|
|
179
|
+
if not in_properties:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
property_match = _IOREG_PROPERTY_RE.search(line)
|
|
183
|
+
if property_match:
|
|
184
|
+
current_node.properties[property_match.group("key")] = _parse_ioreg_value(
|
|
185
|
+
property_match.group("value")
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return _macos_ioreg_nodes_to_devices(roots)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _macos_ioreg_nodes_to_devices(nodes: List[_MacOSIONode]) -> List[USBDevice]:
|
|
192
|
+
devices: List[USBDevice] = []
|
|
193
|
+
for node in nodes:
|
|
194
|
+
child_devices = _macos_ioreg_nodes_to_devices(node.children)
|
|
195
|
+
if node.class_name not in _IOREG_DEVICE_CLASSES:
|
|
196
|
+
devices.extend(child_devices)
|
|
197
|
+
continue
|
|
198
|
+
devices.append(_macos_ioreg_node_to_device(node, child_devices))
|
|
199
|
+
return devices
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _macos_ioreg_node_to_device(node: _MacOSIONode, children: List[USBDevice]) -> USBDevice:
|
|
203
|
+
location_id = _coerce_int(node.properties.get("locationID"))
|
|
204
|
+
device_class = _coerce_int(node.properties.get("bDeviceClass"))
|
|
205
|
+
|
|
206
|
+
dev = USBDevice(
|
|
207
|
+
name=_first_value(
|
|
208
|
+
node.properties.get("USB Product Name"),
|
|
209
|
+
node.properties.get("kUSBProductString"),
|
|
210
|
+
node.name,
|
|
211
|
+
"Unknown",
|
|
212
|
+
)
|
|
213
|
+
or "Unknown",
|
|
214
|
+
vendor_id=_format_hex_id(node.properties.get("idVendor")),
|
|
215
|
+
product_id=_format_hex_id(node.properties.get("idProduct")),
|
|
216
|
+
vendor_name=_first_value(
|
|
217
|
+
node.properties.get("USB Vendor Name"),
|
|
218
|
+
node.properties.get("kUSBVendorString"),
|
|
219
|
+
),
|
|
220
|
+
serial=_first_value(
|
|
221
|
+
node.properties.get("USB Serial Number"),
|
|
222
|
+
node.properties.get("kUSBSerialNumberString"),
|
|
223
|
+
),
|
|
224
|
+
speed=_normalise_ioreg_speed(
|
|
225
|
+
node.properties.get("UsbLinkSpeed"),
|
|
226
|
+
node.properties.get("Device Speed"),
|
|
227
|
+
node.properties.get("USBSpeed"),
|
|
228
|
+
),
|
|
229
|
+
bus=_macos_bus_from_location_id(location_id),
|
|
230
|
+
address=_first_value(
|
|
231
|
+
node.properties.get("USB Address"),
|
|
232
|
+
node.properties.get("kUSBAddress"),
|
|
233
|
+
),
|
|
234
|
+
device_class=_USB_CLASSES.get(f"{device_class:02x}", f"{device_class:02x}")
|
|
235
|
+
if device_class is not None
|
|
236
|
+
else None,
|
|
237
|
+
max_power=_format_current_ma(node.properties.get("UsbPowerSinkAllocation")),
|
|
238
|
+
version=_decode_bcd(node.properties.get("bcdUSB")),
|
|
239
|
+
port=_format_location_id(location_id),
|
|
240
|
+
)
|
|
241
|
+
dev.children.extend(children)
|
|
242
|
+
return dev
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _extract_hex(value: str) -> Optional[str]:
|
|
246
|
+
m = re.search(r"0x([0-9a-fA-F]{4})", str(value))
|
|
247
|
+
return m.group(1).lower() if m else (value.strip() or None)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _format_hex_id(value: object) -> Optional[str]:
|
|
251
|
+
numeric = _coerce_int(value)
|
|
252
|
+
if numeric is not None:
|
|
253
|
+
return f"{numeric:04x}"
|
|
254
|
+
text = _stringify(value)
|
|
255
|
+
return _extract_hex(text) if text else None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _normalise_ioreg_speed(link_speed: object, *fallbacks: object) -> Optional[str]:
|
|
259
|
+
bits_per_second = _coerce_int(link_speed)
|
|
260
|
+
if bits_per_second is not None:
|
|
261
|
+
if bits_per_second <= 2_000_000:
|
|
262
|
+
return "Low Speed"
|
|
263
|
+
if bits_per_second <= 20_000_000:
|
|
264
|
+
return "Full Speed"
|
|
265
|
+
if bits_per_second <= 600_000_000:
|
|
266
|
+
return "High Speed"
|
|
267
|
+
if bits_per_second <= 6_000_000_000:
|
|
268
|
+
return "Super Speed"
|
|
269
|
+
return "Super Speed+"
|
|
270
|
+
|
|
271
|
+
speed_codes = {
|
|
272
|
+
0: "Low Speed",
|
|
273
|
+
1: "Full Speed",
|
|
274
|
+
2: "High Speed",
|
|
275
|
+
3: "Super Speed",
|
|
276
|
+
4: "Super Speed+",
|
|
277
|
+
}
|
|
278
|
+
for value in fallbacks:
|
|
279
|
+
numeric = _coerce_int(value)
|
|
280
|
+
if numeric is not None and numeric in speed_codes:
|
|
281
|
+
return speed_codes[numeric]
|
|
282
|
+
text = _stringify(value)
|
|
283
|
+
if text:
|
|
284
|
+
return _normalise_speed(text)
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _normalise_speed(raw: str) -> Optional[str]:
|
|
289
|
+
if not raw:
|
|
290
|
+
return None
|
|
291
|
+
low = raw.lower().replace("_", " ").strip()
|
|
292
|
+
for label in ("super speed plus", "super speed", "high speed", "full speed", "low speed"):
|
|
293
|
+
if label.replace(" ", "") in low.replace(" ", ""):
|
|
294
|
+
return label.title()
|
|
295
|
+
if "10" in low:
|
|
296
|
+
return "Super Speed+ (10 Gbps)"
|
|
297
|
+
if "5" in low:
|
|
298
|
+
return "Super Speed (5 Gbps)"
|
|
299
|
+
return raw
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _parse_ioreg_value(raw: str) -> object:
|
|
303
|
+
raw = raw.strip()
|
|
304
|
+
if raw.startswith('"') and raw.endswith('"'):
|
|
305
|
+
return raw[1:-1]
|
|
306
|
+
if raw == "Yes":
|
|
307
|
+
return True
|
|
308
|
+
if raw == "No":
|
|
309
|
+
return False
|
|
310
|
+
numeric = _coerce_int(raw)
|
|
311
|
+
if numeric is not None:
|
|
312
|
+
return numeric
|
|
313
|
+
return raw
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _coerce_int(value: object) -> Optional[int]:
|
|
317
|
+
if value is None:
|
|
318
|
+
return None
|
|
319
|
+
if isinstance(value, bool):
|
|
320
|
+
return int(value)
|
|
321
|
+
if isinstance(value, int):
|
|
322
|
+
return value
|
|
323
|
+
if isinstance(value, str):
|
|
324
|
+
text = value.strip()
|
|
325
|
+
if not text:
|
|
326
|
+
return None
|
|
327
|
+
try:
|
|
328
|
+
return int(text, 0)
|
|
329
|
+
except ValueError:
|
|
330
|
+
return None
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _stringify(value: object) -> Optional[str]:
|
|
335
|
+
if value is None:
|
|
336
|
+
return None
|
|
337
|
+
text = str(value).strip()
|
|
338
|
+
return text or None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _first_value(*values: object) -> Optional[str]:
|
|
342
|
+
for value in values:
|
|
343
|
+
text = _stringify(value)
|
|
344
|
+
if text:
|
|
345
|
+
return text
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _strip_ioreg_location(name: str) -> str:
|
|
350
|
+
return re.sub(r"@[0-9A-Fa-f]+$", "", name)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _macos_bus_from_location_id(location_id: Optional[int]) -> Optional[str]:
|
|
354
|
+
if location_id is None:
|
|
355
|
+
return None
|
|
356
|
+
bus = location_id >> 24
|
|
357
|
+
return str(bus) if bus else None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _format_location_id(location_id: Optional[int]) -> Optional[str]:
|
|
361
|
+
if location_id is None:
|
|
362
|
+
return None
|
|
363
|
+
return f"0x{location_id:08x}"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _format_current_ma(value: object) -> Optional[str]:
|
|
367
|
+
current = _coerce_int(value)
|
|
368
|
+
if current is None:
|
|
369
|
+
return None
|
|
370
|
+
return f"{current} mA"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _decode_bcd(value: object) -> Optional[str]:
|
|
374
|
+
numeric = _coerce_int(value)
|
|
375
|
+
if numeric is None:
|
|
376
|
+
return None
|
|
377
|
+
digits = f"{numeric:04x}"
|
|
378
|
+
return f"{int(digits[:2])}.{digits[2:]}"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
# Linux
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
_SYSFS = Path("/sys/bus/usb/devices")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _discover_linux() -> List[USBDevice]:
|
|
389
|
+
if not _SYSFS.is_dir():
|
|
390
|
+
return _discover_linux_lsusb()
|
|
391
|
+
return _build_linux_tree()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _build_linux_tree() -> List[USBDevice]:
|
|
395
|
+
devices_by_path: dict[str, USBDevice] = {}
|
|
396
|
+
root_buses: List[str] = []
|
|
397
|
+
|
|
398
|
+
for entry in sorted(_SYSFS.iterdir()):
|
|
399
|
+
name = entry.name
|
|
400
|
+
# Skip interfaces like 1-1:1.0
|
|
401
|
+
if ":" in name:
|
|
402
|
+
continue
|
|
403
|
+
dev = _read_sysfs_device(entry)
|
|
404
|
+
devices_by_path[name] = dev
|
|
405
|
+
# Root hubs are "usbN"
|
|
406
|
+
if name.startswith("usb"):
|
|
407
|
+
root_buses.append(name)
|
|
408
|
+
|
|
409
|
+
# Build hierarchy: "1-2.3" is child of "1-2", "1-2" is child of "usb1"
|
|
410
|
+
for path, dev in devices_by_path.items():
|
|
411
|
+
if path.startswith("usb"):
|
|
412
|
+
continue
|
|
413
|
+
parent_path = _linux_parent_path(path)
|
|
414
|
+
if parent_path and parent_path in devices_by_path:
|
|
415
|
+
devices_by_path[parent_path].children.append(dev)
|
|
416
|
+
|
|
417
|
+
return [devices_by_path[rb] for rb in sorted(root_buses) if rb in devices_by_path]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _linux_parent_path(path: str) -> Optional[str]:
|
|
421
|
+
# "1-2.3.4" -> "1-2.3"; "1-2" -> "usb1"
|
|
422
|
+
if "." in path:
|
|
423
|
+
return path.rsplit(".", 1)[0]
|
|
424
|
+
m = re.match(r"(\d+)-", path)
|
|
425
|
+
if m:
|
|
426
|
+
return f"usb{m.group(1)}"
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _read_sysfs_device(path: Path) -> USBDevice:
|
|
431
|
+
def _read(attr: str) -> Optional[str]:
|
|
432
|
+
try:
|
|
433
|
+
return (path / attr).read_text().strip() or None
|
|
434
|
+
except (OSError, PermissionError):
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
vid = _read("idVendor")
|
|
438
|
+
pid = _read("idProduct")
|
|
439
|
+
bclass = _read("bDeviceClass")
|
|
440
|
+
speed_mbs = _read("speed")
|
|
441
|
+
speed = f"{speed_mbs} Mbps" if speed_mbs else None
|
|
442
|
+
|
|
443
|
+
return USBDevice(
|
|
444
|
+
name=_read("product") or path.name,
|
|
445
|
+
vendor_id=vid,
|
|
446
|
+
product_id=pid,
|
|
447
|
+
vendor_name=_read("manufacturer"),
|
|
448
|
+
serial=_read("serial"),
|
|
449
|
+
speed=speed,
|
|
450
|
+
bus=_read("busnum"),
|
|
451
|
+
address=_read("devnum"),
|
|
452
|
+
device_class=_USB_CLASSES.get((bclass or "").lower(), bclass),
|
|
453
|
+
driver=_read_driver(path),
|
|
454
|
+
max_power=_read("bMaxPower"),
|
|
455
|
+
version=_read("version"),
|
|
456
|
+
port=path.name,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _read_driver(path: Path) -> Optional[str]:
|
|
461
|
+
driver_link = path / "driver"
|
|
462
|
+
try:
|
|
463
|
+
if driver_link.is_symlink():
|
|
464
|
+
return os.path.basename(os.readlink(str(driver_link)))
|
|
465
|
+
except OSError:
|
|
466
|
+
pass
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _discover_linux_lsusb() -> List[USBDevice]:
|
|
471
|
+
"""Fallback: parse lsusb output (flat list, no tree)."""
|
|
472
|
+
try:
|
|
473
|
+
raw = subprocess.check_output(["lsusb"], stderr=subprocess.DEVNULL, timeout=10)
|
|
474
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
475
|
+
return []
|
|
476
|
+
devices: List[USBDevice] = []
|
|
477
|
+
for line in raw.decode(errors="replace").splitlines():
|
|
478
|
+
m = re.match(
|
|
479
|
+
r"Bus\s+(\d+)\s+Device\s+(\d+):\s+ID\s+([0-9a-f]{4}):([0-9a-f]{4})\s+(.*)",
|
|
480
|
+
line,
|
|
481
|
+
)
|
|
482
|
+
if m:
|
|
483
|
+
devices.append(USBDevice(
|
|
484
|
+
name=m.group(5).strip() or "Unknown",
|
|
485
|
+
vendor_id=m.group(3),
|
|
486
|
+
product_id=m.group(4),
|
|
487
|
+
bus=m.group(1),
|
|
488
|
+
address=m.group(2),
|
|
489
|
+
))
|
|
490
|
+
return devices
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# ---------------------------------------------------------------------------
|
|
494
|
+
# Windows
|
|
495
|
+
# ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
def _discover_windows() -> List[USBDevice]:
|
|
498
|
+
return _discover_windows_powershell()
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _discover_windows_powershell() -> List[USBDevice]:
|
|
502
|
+
ps_script = r"""
|
|
503
|
+
Get-CimInstance Win32_USBControllerDevice | ForEach-Object {
|
|
504
|
+
$dep = $_.Dependent
|
|
505
|
+
$ref = $dep.ToString()
|
|
506
|
+
if ($ref -match 'DeviceID="(.+?)"') {
|
|
507
|
+
$devId = $Matches[1] -replace '\\\\', '\\'
|
|
508
|
+
} else { return }
|
|
509
|
+
$dev = Get-CimInstance Win32_PnPEntity | Where-Object { $_.DeviceID -eq $devId } | Select-Object -First 1
|
|
510
|
+
if (-not $dev) { return }
|
|
511
|
+
$vid = ''; $pid = ''
|
|
512
|
+
if ($devId -match 'VID_([0-9A-Fa-f]{4})') { $vid = $Matches[1].ToLower() }
|
|
513
|
+
if ($devId -match 'PID_([0-9A-Fa-f]{4})') { $pid = $Matches[1].ToLower() }
|
|
514
|
+
[PSCustomObject]@{
|
|
515
|
+
Name = $dev.Name
|
|
516
|
+
DeviceID = $devId
|
|
517
|
+
VID = $vid
|
|
518
|
+
PID = $pid
|
|
519
|
+
Manufacturer = $dev.Manufacturer
|
|
520
|
+
Description = $dev.Description
|
|
521
|
+
Service = $dev.Service
|
|
522
|
+
Status = $dev.Status
|
|
523
|
+
}
|
|
524
|
+
} | ConvertTo-Json -Depth 3
|
|
525
|
+
"""
|
|
526
|
+
try:
|
|
527
|
+
raw = subprocess.check_output(
|
|
528
|
+
["powershell", "-NoProfile", "-Command", ps_script],
|
|
529
|
+
stderr=subprocess.DEVNULL,
|
|
530
|
+
timeout=15,
|
|
531
|
+
)
|
|
532
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
533
|
+
return []
|
|
534
|
+
|
|
535
|
+
text = raw.decode(errors="replace").strip()
|
|
536
|
+
if not text:
|
|
537
|
+
return []
|
|
538
|
+
|
|
539
|
+
data = json.loads(text)
|
|
540
|
+
if isinstance(data, dict):
|
|
541
|
+
data = [data]
|
|
542
|
+
|
|
543
|
+
hubs: dict[str, USBDevice] = {}
|
|
544
|
+
orphans: List[USBDevice] = []
|
|
545
|
+
|
|
546
|
+
for item in data:
|
|
547
|
+
dev_id = item.get("DeviceID", "")
|
|
548
|
+
dev = USBDevice(
|
|
549
|
+
name=item.get("Name") or item.get("Description") or "Unknown",
|
|
550
|
+
vendor_id=item.get("VID") or None,
|
|
551
|
+
product_id=item.get("PID") or None,
|
|
552
|
+
vendor_name=item.get("Manufacturer"),
|
|
553
|
+
driver=item.get("Service"),
|
|
554
|
+
port=dev_id,
|
|
555
|
+
)
|
|
556
|
+
if "hub" in (dev.name or "").lower():
|
|
557
|
+
hubs[dev_id] = dev
|
|
558
|
+
else:
|
|
559
|
+
placed = False
|
|
560
|
+
for hub_id, hub in hubs.items():
|
|
561
|
+
if dev_id.startswith(hub_id.rsplit("\\", 1)[0]):
|
|
562
|
+
hub.children.append(dev)
|
|
563
|
+
placed = True
|
|
564
|
+
break
|
|
565
|
+
if not placed:
|
|
566
|
+
orphans.append(dev)
|
|
567
|
+
|
|
568
|
+
result: List[USBDevice] = list(hubs.values())
|
|
569
|
+
result.extend(orphans)
|
|
570
|
+
return result
|
python_usb/tree.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Render USB device tree to the terminal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import List, TextIO
|
|
7
|
+
|
|
8
|
+
from .device import USBDevice
|
|
9
|
+
|
|
10
|
+
# ANSI color helpers — disabled when output is not a tty.
|
|
11
|
+
_USE_COLOR = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
12
|
+
|
|
13
|
+
_RESET = "\033[0m" if _USE_COLOR else ""
|
|
14
|
+
_BOLD = "\033[1m" if _USE_COLOR else ""
|
|
15
|
+
_DIM = "\033[2m" if _USE_COLOR else ""
|
|
16
|
+
_CYAN = "\033[36m" if _USE_COLOR else ""
|
|
17
|
+
_YELLOW = "\033[33m" if _USE_COLOR else ""
|
|
18
|
+
_GREEN = "\033[32m" if _USE_COLOR else ""
|
|
19
|
+
_MAGENTA = "\033[35m" if _USE_COLOR else ""
|
|
20
|
+
_WHITE = "\033[97m" if _USE_COLOR else ""
|
|
21
|
+
|
|
22
|
+
_PIPE = "│ "
|
|
23
|
+
_TEE = "├── "
|
|
24
|
+
_LAST = "└── "
|
|
25
|
+
_BLANK = " "
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_tree(devices: List[USBDevice], file: TextIO = sys.stdout) -> None:
|
|
29
|
+
if not devices:
|
|
30
|
+
print("No USB devices found.", file=file)
|
|
31
|
+
return
|
|
32
|
+
for i, dev in enumerate(devices):
|
|
33
|
+
_render_node(dev, "", i == len(devices) - 1, file)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _render_node(dev: USBDevice, prefix: str, is_last: bool, file: TextIO) -> None:
|
|
37
|
+
connector = _LAST if is_last else _TEE
|
|
38
|
+
|
|
39
|
+
title = f"{_BOLD}{_WHITE}{dev.name}{_RESET}"
|
|
40
|
+
id_str = ""
|
|
41
|
+
if dev.id_pair:
|
|
42
|
+
id_str = f" {_CYAN}[{dev.id_pair}]{_RESET}"
|
|
43
|
+
print(f"{prefix}{connector}{title}{id_str}", file=file)
|
|
44
|
+
|
|
45
|
+
child_prefix = prefix + (_BLANK if is_last else _PIPE)
|
|
46
|
+
|
|
47
|
+
details = _detail_lines(dev)
|
|
48
|
+
for line in details:
|
|
49
|
+
print(f"{child_prefix}{_DIM}{line}{_RESET}", file=file)
|
|
50
|
+
|
|
51
|
+
for i, child in enumerate(dev.children):
|
|
52
|
+
_render_node(child, child_prefix, i == len(dev.children) - 1, file)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _detail_lines(dev: USBDevice) -> List[str]:
|
|
56
|
+
lines: List[str] = []
|
|
57
|
+
|
|
58
|
+
if dev.vendor_name:
|
|
59
|
+
lines.append(f"Vendor: {dev.vendor_name}")
|
|
60
|
+
if dev.serial:
|
|
61
|
+
lines.append(f"Serial: {dev.serial}")
|
|
62
|
+
if dev.speed:
|
|
63
|
+
lines.append(f"Speed: {dev.speed}")
|
|
64
|
+
if dev.device_class:
|
|
65
|
+
lines.append(f"Class: {dev.device_class}")
|
|
66
|
+
if dev.driver:
|
|
67
|
+
lines.append(f"Driver: {dev.driver}")
|
|
68
|
+
if dev.bus or dev.address:
|
|
69
|
+
loc_parts = []
|
|
70
|
+
if dev.bus:
|
|
71
|
+
loc_parts.append(f"Bus {dev.bus}")
|
|
72
|
+
if dev.address:
|
|
73
|
+
loc_parts.append(f"Dev {dev.address}")
|
|
74
|
+
lines.append(f"Location: {', '.join(loc_parts)}")
|
|
75
|
+
if dev.max_power:
|
|
76
|
+
lines.append(f"Power: {dev.max_power}")
|
|
77
|
+
if dev.version:
|
|
78
|
+
lines.append(f"USB Ver: {dev.version}")
|
|
79
|
+
|
|
80
|
+
return lines
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-usb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: List all USB devices in a developer-friendly tree view (Windows/macOS/Linux)
|
|
5
|
+
Author: Peter
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/peterdemin/python-usb
|
|
8
|
+
Project-URL: Issues, https://github.com/peterdemin/python-usb/issues
|
|
9
|
+
Keywords: usb,devices,hardware,tree,cli,debug
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: System :: Hardware
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# python-usb
|
|
30
|
+
|
|
31
|
+
List all USB devices in a developer-friendly tree view. Zero dependencies — works on **macOS**, **Linux**, and **Windows**.
|
|
32
|
+
|
|
33
|
+
## Dev
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
python3 -m venv .venv
|
|
37
|
+
source .venv/bin/activate
|
|
38
|
+
python -m pip install -e .
|
|
39
|
+
python-usb
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
pip install python-usb
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
python-usb
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Example output (macOS):
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
└── USB 3.1 Bus
|
|
58
|
+
├── USB Keyboard [05ac:024f]
|
|
59
|
+
│ Vendor: Apple Inc.
|
|
60
|
+
│ Serial: ABCD1234
|
|
61
|
+
│ Speed: Full Speed
|
|
62
|
+
│ Power: 50 mA
|
|
63
|
+
├── USB Mouse [046d:c077]
|
|
64
|
+
│ Vendor: Logitech
|
|
65
|
+
│ Speed: Low Speed
|
|
66
|
+
└── USB Hub [2109:2817]
|
|
67
|
+
Vendor: VIA Labs, Inc.
|
|
68
|
+
Speed: Super Speed
|
|
69
|
+
└── External SSD [0781:5583]
|
|
70
|
+
Vendor: SanDisk
|
|
71
|
+
Serial: 12345678
|
|
72
|
+
Speed: Super Speed
|
|
73
|
+
Power: 896 mA
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### JSON output
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
python-usb --json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### What it shows
|
|
83
|
+
|
|
84
|
+
| Field | Description |
|
|
85
|
+
|-----------|----------------------------------|
|
|
86
|
+
| Name | Product / device name |
|
|
87
|
+
| ID | `vendor_id:product_id` (hex) |
|
|
88
|
+
| Vendor | Manufacturer name |
|
|
89
|
+
| Serial | Serial number |
|
|
90
|
+
| Speed | Low / Full / High / Super Speed |
|
|
91
|
+
| Class | USB device class |
|
|
92
|
+
| Driver | Kernel driver in use |
|
|
93
|
+
| Location | Bus and device number |
|
|
94
|
+
| Power | Max power draw |
|
|
95
|
+
| USB Ver | USB specification version |
|
|
96
|
+
|
|
97
|
+
## How it works
|
|
98
|
+
|
|
99
|
+
| Platform | Method |
|
|
100
|
+
|----------|-------------------------------------------|
|
|
101
|
+
| macOS | `system_profiler SPUSBDataType -json` |
|
|
102
|
+
| Linux | `/sys/bus/usb/devices` sysfs (or `lsusb`) |
|
|
103
|
+
| Windows | PowerShell + WMI `Win32_PnPEntity` |
|
|
104
|
+
|
|
105
|
+
No compiled extensions, no `libusb` dependency.
|
|
106
|
+
|
|
107
|
+
## Publish to PyPI
|
|
108
|
+
|
|
109
|
+
1. Update the version in `pyproject.toml`.
|
|
110
|
+
2. Create a PyPI API token at <https://pypi.org/manage/account/token/>.
|
|
111
|
+
3. Install the publishing tools:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
python -m pip install --upgrade build twine
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
4. Build the distribution files:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
python -m build
|
|
121
|
+
python -m twine check dist/*
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
5. Upload to PyPI:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
python -m twine upload dist/*
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Use `__token__` as the username and your PyPI API token as the password when prompted.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
python_usb/__init__.py,sha256=5gHyFp1cos2WV8-4e-gPqKraFOBG0Xy-939RIOa7qxk,97
|
|
2
|
+
python_usb/cli.py,sha256=49qasx7dhFHTis54QDkfZ9d64_xv9a2ul_1rP-GteYU,1595
|
|
3
|
+
python_usb/device.py,sha256=s8wfOB5BnpkQ5JA4tCejvKzGbKfmg672rZx3HtuxTp0,864
|
|
4
|
+
python_usb/discover.py,sha256=OK9VNqPaFJtNjIX9RRTYMzQH4uW0tXRBki9Y_uO--_M,17219
|
|
5
|
+
python_usb/tree.py,sha256=ARuJsL7TmDK4M7A5DYVAT1HfRspiRszZurfBzoaBr1I,2441
|
|
6
|
+
python_usb-0.1.0.dist-info/licenses/LICENSE,sha256=ok9ewe8oJ1DDQUxTp-TFSsX6XX7_5d6J5lgS2AFyfdM,1062
|
|
7
|
+
python_usb-0.1.0.dist-info/METADATA,sha256=jR0nZ6cJxYV3gTFzXahaDuWGpQrlCm76cTkr9NG1vvQ,3476
|
|
8
|
+
python_usb-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
python_usb-0.1.0.dist-info/entry_points.txt,sha256=HL51PM6DZBabhAEItPkgYfJXzBXvPeuTfwS5ggYaKK4,51
|
|
10
|
+
python_usb-0.1.0.dist-info/top_level.txt,sha256=JdWrMCUbE5EkilibX6cNQwW05K_s1NabSmmRS-wxx68,11
|
|
11
|
+
python_usb-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peter
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
python_usb
|