Skip to content

Commit f338974

Browse files
committed
Add Windows device capacity support via DeviceIoControl, closes #52
Add windows-latest to CI matrix, skip bash-only test on Windows
1 parent 15409e0 commit f338974

3 files changed

Lines changed: 95 additions & 7 deletions

File tree

.github/workflows/python.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
strategy:
1212
matrix:
1313
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
14-
os: ["macos-latest", "ubuntu-latest"]
14+
os: ["macos-latest", "ubuntu-latest", "windows-latest"]
1515
runs-on: ${{ matrix.os }}
1616
steps:
1717
- name: "GitHub Checks it out :sunglasses-face:"
@@ -24,6 +24,7 @@ jobs:
2424
cache: 'pip'
2525

2626
- name: Verify Test Case Names Unique
27+
if: runner.os != 'Windows'
2728
run: |
2829
./tests/test_unique_testcase_names.sh
2930

bitmath/__init__.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,19 @@
5858
from collections.abc import Generator, Iterable, Iterator
5959
from typing import IO, Any
6060

61-
# For device capacity reading in query_device_capacity(). Only supported
62-
# on posix systems for now. Will be addressed in issue #52 on GitHub.
61+
# For device capacity reading in query_device_capacity().
6362
if os.name == 'posix':
6463
import stat
6564
import fcntl
6665
import struct
66+
elif os.name == 'nt':
67+
import ctypes
68+
import ctypes.wintypes
69+
import msvcrt
6770

71+
#: Platforms where :func:`query_device_capacity` is supported.
72+
#: Corresponds to possible values of :data:`os.name`.
73+
SUPPORTED_PLATFORMS = frozenset({'posix', 'nt'})
6874

6975
__all__ = ['Bit', 'Byte', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB',
7076
'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'Kib',
@@ -1285,6 +1291,56 @@ def best_prefix(bytes: Bitmath | int | float, system: int = NIST) -> Bitmath:
12851291
return Byte(value).best_prefix(system=system)
12861292

12871293

1294+
def _query_device_capacity_windows(device_fd: IO[Any]) -> int:
1295+
"""Return device capacity in bytes on Windows via DeviceIoControl.
1296+
1297+
Windows physical disk paths look like ``\\\\.\\PhysicalDrive0``.
1298+
Raises :class:`ValueError` if the file descriptor is not a physical device.
1299+
Raises :class:`OSError` if the DeviceIoControl call fails.
1300+
"""
1301+
if not device_fd.name.startswith('\\\\.\\'):
1302+
raise ValueError("The file descriptor provided is not of a device type")
1303+
1304+
IOCTL_DISK_GET_DRIVE_GEOMETRY_EX = 0x000700A0
1305+
1306+
class DISK_GEOMETRY(ctypes.Structure):
1307+
_fields_ = [
1308+
('Cylinders', ctypes.c_longlong),
1309+
('MediaType', ctypes.c_uint),
1310+
('TracksPerCylinder', ctypes.c_ulong),
1311+
('SectorsPerTrack', ctypes.c_ulong),
1312+
('BytesPerSector', ctypes.c_ulong),
1313+
]
1314+
1315+
class DISK_GEOMETRY_EX(ctypes.Structure):
1316+
_fields_ = [
1317+
('Geometry', DISK_GEOMETRY),
1318+
('DiskSize', ctypes.c_longlong),
1319+
('Data', ctypes.c_byte * 1),
1320+
]
1321+
1322+
geometry = DISK_GEOMETRY_EX()
1323+
bytes_returned = ctypes.wintypes.DWORD(0)
1324+
handle = msvcrt.get_osfhandle(device_fd.fileno())
1325+
1326+
result = ctypes.windll.kernel32.DeviceIoControl(
1327+
handle,
1328+
IOCTL_DISK_GET_DRIVE_GEOMETRY_EX,
1329+
None,
1330+
0,
1331+
ctypes.byref(geometry),
1332+
ctypes.sizeof(geometry),
1333+
ctypes.byref(bytes_returned),
1334+
None,
1335+
)
1336+
1337+
if not result:
1338+
error_code = ctypes.windll.kernel32.GetLastError()
1339+
raise OSError(f"DeviceIoControl failed with error code: {error_code}")
1340+
1341+
return geometry.DiskSize
1342+
1343+
12881344
def query_device_capacity(device_fd: IO[Any]) -> Byte:
12891345
"""Create bitmath instances of the capacity of a system block device
12901346
@@ -1300,13 +1356,16 @@ def query_device_capacity(device_fd: IO[Any]) -> Byte:
13001356
* http://stackoverflow.com/a/9764508/263969
13011357
13021358
:param file device_fd: A ``file`` object of the device to query the
1303-
capacity of (as in ``get_device_capacity(open("/dev/sda"))``).
1359+
capacity of. On Linux/macOS: ``open("/dev/sda", "rb")``. On Windows:
1360+
``open(r'\\\\.\\PhysicalDrive0', 'rb')`` (requires administrator privileges).
13041361
13051362
:return: a bitmath :class:`bitmath.Byte` instance equivalent to the
13061363
capacity of the target device in bytes.
13071364
"""
1308-
if os.name != 'posix':
1365+
if os.name not in SUPPORTED_PLATFORMS:
13091366
raise NotImplementedError(f"'bitmath.query_device_capacity' is not supported on this platform: {os.name}")
1367+
if os.name == 'nt':
1368+
return Byte(_query_device_capacity_windows(device_fd))
13101369

13111370
s = os.stat(device_fd.name).st_mode
13121371
if not stat.S_ISBLK(s):

tests/test_query_device_capacity.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ def nested(*contexts):
5050
non_device_file = mock.MagicMock('file')
5151
non_device_file.name = "/home/"
5252

53+
windows_device = mock.MagicMock('file')
54+
windows_device.fileno = mock.Mock(return_value=4)
55+
windows_device.name = r'\\.\PhysicalDrive0'
56+
57+
windows_non_device = mock.MagicMock('file')
58+
windows_non_device.name = r'C:\somefile.txt'
59+
5360

5461
class TestQueryDeviceCapacity(TestCase):
5562
def test_query_device_capacity_linux_everything_is_wonderful(self):
@@ -116,8 +123,29 @@ def test_query_device_capacity_device_not_block(self):
116123

117124
self.assertEqual(ioctl.call_count, 0)
118125

119-
def test_query_device_capacity_non_posix_system_fails(self):
120-
"""query device capacity fails on a non-posix host"""
126+
def test_query_device_capacity_windows_everything_is_wonderful(self):
127+
"""query device capacity works on a happy Windows host"""
128+
expected_bytes = 1_000_000_000_000 # 1 TB
129+
130+
with mock.patch('bitmath._query_device_capacity_windows', return_value=expected_bytes):
131+
with mock.patch('bitmath.os.name', 'nt'):
132+
result = bitmath.query_device_capacity(windows_device)
133+
134+
self.assertEqual(result, bitmath.Byte(expected_bytes))
135+
136+
def test_query_device_capacity_windows_non_device_fails(self):
137+
"""query device capacity rejects a non-device path on Windows"""
121138
with mock.patch('bitmath.os.name', 'nt'):
139+
with self.assertRaises(ValueError):
140+
bitmath.query_device_capacity(windows_non_device)
141+
142+
def test_query_device_capacity_unsupported_platform_fails(self):
143+
"""query device capacity fails on an unsupported platform"""
144+
# Derive a value that is guaranteed not to be in SUPPORTED_PLATFORMS.
145+
unsupported = next(
146+
p for p in ('os2', 'java', 'riscos', 'ce')
147+
if p not in bitmath.SUPPORTED_PLATFORMS
148+
)
149+
with mock.patch('bitmath.os.name', unsupported):
122150
with self.assertRaises(NotImplementedError):
123151
bitmath.query_device_capacity(device)

0 commit comments

Comments
 (0)