python-usb 0.1.0__tar.gz

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.
@@ -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,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,106 @@
1
+ # python-usb
2
+
3
+ List all USB devices in a developer-friendly tree view. Zero dependencies — works on **macOS**, **Linux**, and **Windows**.
4
+
5
+ ## Dev
6
+
7
+ ```
8
+ python3 -m venv .venv
9
+ source .venv/bin/activate
10
+ python -m pip install -e .
11
+ python-usb
12
+ ```
13
+
14
+ ## Install
15
+
16
+ ```
17
+ pip install python-usb
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```
23
+ python-usb
24
+ ```
25
+
26
+ Example output (macOS):
27
+
28
+ ```
29
+ └── USB 3.1 Bus
30
+ ├── USB Keyboard [05ac:024f]
31
+ │ Vendor: Apple Inc.
32
+ │ Serial: ABCD1234
33
+ │ Speed: Full Speed
34
+ │ Power: 50 mA
35
+ ├── USB Mouse [046d:c077]
36
+ │ Vendor: Logitech
37
+ │ Speed: Low Speed
38
+ └── USB Hub [2109:2817]
39
+ Vendor: VIA Labs, Inc.
40
+ Speed: Super Speed
41
+ └── External SSD [0781:5583]
42
+ Vendor: SanDisk
43
+ Serial: 12345678
44
+ Speed: Super Speed
45
+ Power: 896 mA
46
+ ```
47
+
48
+ ### JSON output
49
+
50
+ ```
51
+ python-usb --json
52
+ ```
53
+
54
+ ### What it shows
55
+
56
+ | Field | Description |
57
+ |-----------|----------------------------------|
58
+ | Name | Product / device name |
59
+ | ID | `vendor_id:product_id` (hex) |
60
+ | Vendor | Manufacturer name |
61
+ | Serial | Serial number |
62
+ | Speed | Low / Full / High / Super Speed |
63
+ | Class | USB device class |
64
+ | Driver | Kernel driver in use |
65
+ | Location | Bus and device number |
66
+ | Power | Max power draw |
67
+ | USB Ver | USB specification version |
68
+
69
+ ## How it works
70
+
71
+ | Platform | Method |
72
+ |----------|-------------------------------------------|
73
+ | macOS | `system_profiler SPUSBDataType -json` |
74
+ | Linux | `/sys/bus/usb/devices` sysfs (or `lsusb`) |
75
+ | Windows | PowerShell + WMI `Win32_PnPEntity` |
76
+
77
+ No compiled extensions, no `libusb` dependency.
78
+
79
+ ## Publish to PyPI
80
+
81
+ 1. Update the version in `pyproject.toml`.
82
+ 2. Create a PyPI API token at <https://pypi.org/manage/account/token/>.
83
+ 3. Install the publishing tools:
84
+
85
+ ```
86
+ python -m pip install --upgrade build twine
87
+ ```
88
+
89
+ 4. Build the distribution files:
90
+
91
+ ```
92
+ python -m build
93
+ python -m twine check dist/*
94
+ ```
95
+
96
+ 5. Upload to PyPI:
97
+
98
+ ```
99
+ python -m twine upload dist/*
100
+ ```
101
+
102
+ Use `__token__` as the username and your PyPI API token as the password when prompted.
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "python-usb"
7
+ version = "0.1.0"
8
+ description = "List all USB devices in a developer-friendly tree view (Windows/macOS/Linux)"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.8"
12
+ authors = [{name = "Peter"}]
13
+ keywords = ["usb", "devices", "hardware", "tree", "cli", "debug"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.8",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: System :: Hardware",
28
+ "Topic :: Utilities",
29
+ ]
30
+
31
+ [project.scripts]
32
+ python-usb = "python_usb.cli:main"
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/peterdemin/python-usb"
36
+ Issues = "https://github.com/peterdemin/python-usb/issues"
@@ -0,0 +1,3 @@
1
+ """python-usb: List all USB devices in a developer-friendly tree view."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
@@ -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 ""
@@ -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
@@ -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,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ python_usb/__init__.py
5
+ python_usb/cli.py
6
+ python_usb/device.py
7
+ python_usb/discover.py
8
+ python_usb/tree.py
9
+ python_usb.egg-info/PKG-INFO
10
+ python_usb.egg-info/SOURCES.txt
11
+ python_usb.egg-info/dependency_links.txt
12
+ python_usb.egg-info/entry_points.txt
13
+ python_usb.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ python-usb = python_usb.cli:main
@@ -0,0 +1 @@
1
+ python_usb
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+