"""Find SD cards (removable disks) on Windows and macOS. Safety first: only USB/SD/MMC-attached disks that are not the system or boot disk and not larger than MAX_CARD_BYTES are ever offered. Parsers are pure functions over the OS tool output so they can be tested without hardware. """ from __future__ import annotations import json import plistlib import subprocess import sys from dataclasses import dataclass, field MAX_CARD_BYTES = 256 * 1024**3 # larger "removable" disks are probably not SD cards CARD_BUSES = {"USB", "SD", "MMC"} @dataclass class Volume: mount: str # "E:\\" on Windows, "/Volumes/KOTA" on macOS ("" if not mounted) filesystem: str # "FAT32", "exFAT", "MS-DOS FAT32", ... label: str @dataclass class Disk: id: str # Windows disk number / macOS "/dev/disk4" name: str size: int bus: str volumes: list[Volume] = field(default_factory=list) has_media: bool = True @property def size_text(self) -> str: return f"{self.size / 1e9:.1f} GB" if self.size else "no card" @property def label(self) -> str: vols = ", ".join(f"{v.mount or '-'} {v.filesystem} '{v.label}'" for v in self.volumes) or "no volume" return f"Disk {self.id}: {self.name} - {self.size_text} ({self.bus}) - {vols}" # ------------------------------------------------------------------ Windows WIN_LIST_PS = r""" $ErrorActionPreference = 'SilentlyContinue' # Filter by bus BEFORE touching partitions: querying volumes of every # internal disk made this take >30 s on a machine with several HDDs. $out = @(Get-Disk | Where-Object { -not $_.IsSystem -and -not $_.IsBoot -and @('USB','SD','MMC') -contains [string]$_.BusType } | ForEach-Object { $d = $_ $vols = @(Get-Partition -DiskNumber $d.Number | ForEach-Object { $v = $_ | Get-Volume [pscustomobject]@{ DriveLetter = [string]$_.DriveLetter; FileSystem = [string]$v.FileSystem; Label = [string]$v.FileSystemLabel } }) [pscustomobject]@{ Number = $d.Number; FriendlyName = $d.FriendlyName; Size = [int64]$d.Size; BusType = [string]$d.BusType; Volumes = $vols } }) ConvertTo-Json -InputObject $out -Depth 4 -Compress """ def parse_windows(json_text: str) -> list[Disk]: data = json.loads(json_text or "[]") if isinstance(data, dict): # ConvertTo-Json quirk for one element data = [data] disks = [] for d in data: if str(d.get("BusType")) not in CARD_BUSES: continue vols = d.get("Volumes") or [] if isinstance(vols, dict): vols = [vols] volumes = [Volume(f"{v['DriveLetter']}:\\" if v.get("DriveLetter") and v["DriveLetter"] not in ("\x00", "") else "", v.get("FileSystem") or "", v.get("Label") or "") for v in vols] size = int(d.get("Size") or 0) disks.append(Disk(str(d["Number"]), d.get("FriendlyName") or "Disk", size, str(d["BusType"]), [v for v in volumes if v.mount or v.filesystem], has_media=size > 0)) return disks # -------------------------------------------------------------------- macOS def parse_macos(list_plist: bytes, infos: dict[str, bytes]) -> list[Disk]: """`diskutil list -plist physical` + `diskutil info -plist ` for each.""" listing = plistlib.loads(list_plist) disks = [] for entry in listing.get("AllDisksAndPartitions", []): ident = entry.get("DeviceIdentifier", "") info = plistlib.loads(infos[ident]) if ident in infos else {} protocol = str(info.get("BusProtocol", "")) removable = bool(info.get("RemovableMedia") or info.get("Removable") or info.get("Ejectable")) internal = bool(info.get("Internal", True)) is_root = any(p.get("MountPoint") == "/" for p in entry.get("Partitions", []) + entry.get("APFSVolumes", [])) if ident == "disk0" or is_root or not (removable or not internal or protocol in ("USB", "Secure Digital", "SD")): continue vols = [Volume(p.get("MountPoint", ""), p.get("Content", ""), p.get("VolumeName", "")) for p in entry.get("Partitions", [])] size = int(entry.get("Size") or info.get("TotalSize") or 0) bus = "SD" if "Secure Digital" in protocol or protocol == "SD" else (protocol or "USB") disks.append(Disk(f"/dev/{ident}", info.get("MediaName", "Disk"), size, bus, vols, has_media=size > 0)) return disks # ------------------------------------------------------------------ public def list_cards() -> tuple[list[Disk], list[Disk]]: """(cards that may be formatted, readers/disks shown but not offered).""" if sys.platform == "win32": # Driver-level listing (milliseconds, 1.5 s worst case) instead of # Get-Disk/WMI, which stalled 5-21 s on an empty card-reader slot. from .win_disks import list_disks disks = list_disks(CARD_BUSES) elif sys.platform == "darwin": lst = subprocess.run(["diskutil", "list", "-plist", "physical"], capture_output=True, timeout=30).stdout infos = {} for e in plistlib.loads(lst).get("AllDisksAndPartitions", []): ident = e.get("DeviceIdentifier", "") infos[ident] = subprocess.run(["diskutil", "info", "-plist", ident], capture_output=True, timeout=30).stdout disks = parse_macos(lst, infos) else: return [], [] ok = [d for d in disks if d.has_media and 0 < d.size <= MAX_CARD_BYTES] return ok, [d for d in disks if d not in ok]