Skip to content

Commit ad480b6

Browse files
authored
Merge pull request #126 from timlnx/fixup-query-capacity-stuff
Reworking query_*_capacity. Closes #125
2 parents 2d0508d + 1ec0fb4 commit ad480b6

7 files changed

Lines changed: 380 additions & 123 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ All unit values are normalized to bits internally; conversion between units happ
6161
- `getsize(path, ...)` — file size with automatic prefix selection
6262
- `listdir(search_base, ...)` — recursive directory listing with sizes
6363
- `parse_string(s)` / `parse_string_unsafe(s, system=SI)` — string → bitmath object
64-
- `query_device_capacity(device_fd)` — POSIX device capacity (Linux/macOS)
64+
- `query_capacity(path)` — volume/mount-point capacity as a `Capacity(total, used, free)` NamedTuple of `Byte` instances; cross-platform, no elevated privileges required
65+
- `query_device_capacity(device_fd)` — raw physical block device capacity (Linux: requires root; Windows: requires administrator; macOS: raises `NotImplementedError` due to SIP)
6566

6667
**Constants:** `NIST`, `SI`, `NIST_PREFIXES`, `SI_PREFIXES`, `ALL_UNIT_TYPES`
6768

Makefile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,10 @@ pypitest: build
112112
@echo "#############################################"
113113
. $(NAME)env3/bin/activate && pip install twine && twine upload --repository testpypi dist/*
114114

115-
# usage example: make tag TAG=1.1.0-1
115+
# usage example: make tag TAG=v1.1.0-1
116116
tag:
117+
@if [ -z "$(TAG)" ]; then echo "ERROR: TAG is required. Example: make tag TAG=v2.0.0"; exit 1; fi
118+
@case "$(TAG)" in v*) ;; *) echo "ERROR: TAG must start with 'v'. Got: '$(TAG)'. Example: make tag TAG=v2.0.0"; exit 1 ;; esac
117119
git tag -s -m $(TAG) $(TAG)
118120

119121
clean:

NEWS.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ Breaking Changes
5454
``"Byte"`` or ``"Bit"`` will need to be updated. The class names
5555
themselves are unchanged.
5656

57+
**query_device_capacity() on macOS**
58+
:func:`bitmath.query_device_capacity` now raises
59+
:exc:`NotImplementedError` on macOS. System Integrity Protection
60+
(SIP) blocks raw block device access even for root, making the
61+
previous ioctl path unreliable. Use :func:`bitmath.query_capacity`
62+
instead.
63+
5764
**Build and install**
5865
``setup.py`` and ``setup.py.in`` are gone. Installation is
5966
``pip install bitmath``. Source builds use ``python -m build``.
@@ -101,6 +108,17 @@ still works exactly the same way. What 2.0.0 adds on top of that:
101108
family is now preserved. Closes `issue #95
102109
<https://github.com/timlnx/bitmath/issues/95>`_.
103110

111+
**query_capacity() — recommended volume size API**
112+
New :func:`bitmath.query_capacity` returns a :class:`bitmath.Capacity`
113+
NamedTuple of ``(total, used, free)`` :class:`bitmath.Bitmath`
114+
instances for any path or mount point. Works cross-platform without
115+
elevated privileges. This is the recommended API for "how big is
116+
this volume?" queries. Accepts ``bestprefix=True`` (default) to get
117+
already human-readable units (e.g. ``GiB``) and ``system=bitmath.SI``
118+
to opt into decimal prefixes instead of the default NIST binary
119+
prefixes. Set ``bestprefix=False`` to receive raw
120+
:class:`bitmath.Byte` instances.
121+
104122
**Windows device capacity**
105123
:func:`bitmath.query_device_capacity` now works on Windows via
106124
``DeviceIoControl``. Open the device as
@@ -110,6 +128,13 @@ still works exactly the same way. What 2.0.0 adds on top of that:
110128
platforms where the function is available. Closes `issue #52
111129
<https://github.com/timlnx/bitmath/issues/52>`_.
112130

131+
**query_device_capacity() Linux buffer fix**
132+
:func:`bitmath.query_device_capacity` on Linux was passing an
133+
integer where an ioctl buffer was required, causing
134+
``OSError: [Errno 14] Bad address``. The call now correctly
135+
allocates a zero-filled buffer of the proper size via
136+
``b'\\x00' * struct.calcsize(fmt)``.
137+
113138
**Flexible string parsing**
114139
:func:`bitmath.parse_string` with ``strict=False`` accepts
115140
ambiguous input such as ``"1g"`` or ``"1GB"`` and resolves it to

bitmath/__init__.py

Lines changed: 89 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,13 @@
5252
import os
5353
import os.path
5454
import platform
55+
import re
56+
import shutil
5557
import sys
5658
import threading
5759

5860
from collections.abc import Generator, Iterable, Iterator
59-
from typing import IO, Any
61+
from typing import IO, Any, NamedTuple, Union
6062

6163
# For device capacity reading in query_device_capacity().
6264
if os.name == 'posix':
@@ -69,7 +71,8 @@
6971
import msvcrt
7072

7173
#: Platforms where :func:`query_device_capacity` is supported.
72-
#: Corresponds to possible values of :data:`os.name`.
74+
#: Corresponds to possible values of :data:`os.name`. macOS (Darwin)
75+
#: is not supported due to SIP restrictions on raw block device access.
7376
SUPPORTED_PLATFORMS = frozenset({'posix', 'nt'})
7477

7578
__all__ = ['Bit', 'Byte', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB',
@@ -78,7 +81,8 @@
7881
'Pb', 'Eb', 'Zb', 'Yb', 'getsize', 'listdir', 'format',
7982
'format_string', 'format_plural', 'parse_string', 'parse_string_unsafe',
8083
'sum', 'ALL_UNIT_TYPES', 'NIST', 'NIST_PREFIXES', 'NIST_STEPS',
81-
'SI', 'SI_PREFIXES', 'SI_STEPS']
84+
'SI', 'SI_PREFIXES', 'SI_STEPS', 'Capacity', 'query_capacity',
85+
'query_device_capacity']
8286

8387
#: A list of all the valid prefix unit types. Mostly for reference,
8488
#: also used by the CLI tool as valid types
@@ -1342,31 +1346,33 @@ class DISK_GEOMETRY_EX(ctypes.Structure):
13421346

13431347

13441348
def query_device_capacity(device_fd: IO[Any]) -> Byte:
1345-
"""Create bitmath instances of the capacity of a system block device
1349+
"""Query the raw physical capacity of a block device.
13461350
1347-
Make one or more ioctl request to query the capacity of a block
1348-
device. Perform any processing required to compute the final capacity
1349-
value. Return the device capacity in bytes as a :class:`bitmath.Byte`
1350-
instance.
1351-
1352-
Thanks to the following resources for help figuring this out Linux/Mac
1353-
ioctl's for querying block device sizes:
1351+
Most users should prefer :func:`query_capacity`. This function is for
1352+
callers who need raw physical device capacity (e.g. disk imaging tools).
1353+
Requires root on Linux and administrator on Windows. Not supported on
1354+
macOS (SIP restriction).
13541355
1355-
* http://stackoverflow.com/a/12925285/263969
1356-
* http://stackoverflow.com/a/9764508/263969
1357-
1358-
:param file device_fd: A ``file`` object of the device to query the
1359-
capacity of. On Linux/macOS: ``open("/dev/sda", "rb")``. On Windows:
1360-
``open(r'\\\\.\\PhysicalDrive0', 'rb')`` (requires administrator privileges).
1356+
:param file device_fd: A ``file`` object of the device to query.
1357+
On Linux: ``open("/dev/sda", "rb")`` (requires root).
1358+
On Windows: ``open(r'\\\\.\\PhysicalDrive0', 'rb')`` (requires administrator).
13611359
13621360
:return: a bitmath :class:`bitmath.Byte` instance equivalent to the
13631361
capacity of the target device in bytes.
1362+
:raises NotImplementedError: on macOS or any other unsupported platform.
1363+
:raises ValueError: if the file descriptor is not a block device.
13641364
"""
13651365
if os.name not in SUPPORTED_PLATFORMS:
13661366
raise NotImplementedError(f"'bitmath.query_device_capacity' is not supported on this platform: {os.name}")
13671367
if os.name == 'nt':
13681368
return Byte(_query_device_capacity_windows(device_fd))
13691369

1370+
if platform.system() == 'Darwin':
1371+
raise NotImplementedError(
1372+
"query_device_capacity is not supported on macOS; "
1373+
"SIP blocks raw block device access. Use query_capacity() instead."
1374+
)
1375+
13701376
s = os.stat(device_fd.name).st_mode
13711377
if not stat.S_ISBLK(s):
13721378
raise ValueError("The file descriptor provided is not of a device type")
@@ -1411,45 +1417,6 @@ def query_device_capacity(device_fd: IO[Any]) -> Byte:
14111417
# BLKGETSIZE64.
14121418
"func": lambda x: x["BLKGETSIZE64"]
14131419
},
1414-
# ioctls for the "Darwin" (Mac OS X) platform
1415-
"Darwin": {
1416-
"request_params": [
1417-
# A list of parameters to calculate the block size.
1418-
#
1419-
# ( PARAM_NAME , FORMAT_CHAR , REQUEST_CODE )
1420-
("DKIOCGETBLOCKCOUNT", "L", 0x40086419),
1421-
# Per <sys/disk.h>: get media's block count - uint64_t
1422-
#
1423-
# As in the BLKGETSIZE64 example, an unsigned 64 bit
1424-
# integer will use the 'L' formatting character
1425-
("DKIOCGETBLOCKSIZE", "I", 0x40046418)
1426-
# Per <sys/disk.h>: get media's block size - uint32_t
1427-
#
1428-
# This request returns an unsigned 32 bit integer, or
1429-
# in other words: just a normal integer (or 'int' c
1430-
# type). That should require 4 bytes of space for
1431-
# buffering. According to the struct modules
1432-
# 'Formatting Characters' chart:
1433-
#
1434-
# * Character 'I' - Unsigned Int C Type (uint32_t) - Loads into a Python int type
1435-
],
1436-
# OS X doesn't have a direct equivalent to the Linux
1437-
# BLKGETSIZE64 request. Instead, we must request how many
1438-
# blocks (or "sectors") are on the disk, and the size (in
1439-
# bytes) of each block. Finally, multiply the two together
1440-
# to obtain capacity:
1441-
#
1442-
# n Block * y Byte
1443-
# capacity (bytes) = -------
1444-
# 1 Block
1445-
"func": lambda x: x["DKIOCGETBLOCKCOUNT"] * x["DKIOCGETBLOCKSIZE"]
1446-
# This expression simply accepts a dictionary ``x`` as a
1447-
# parameter, and then returns the result of multiplying
1448-
# the two named dictionary items together. In this case,
1449-
# that means multiplying ``DKIOCGETBLOCKCOUNT``, the total
1450-
# number of blocks, by ``DKIOCGETBLOCKSIZE``, the size of
1451-
# each block in bytes.
1452-
}
14531420
}
14541421

14551422
platform_params = ioctl_map[platform.system()]
@@ -1463,7 +1430,7 @@ def query_device_capacity(device_fd: IO[Any]) -> Byte:
14631430
# conditions for some possible errors. Really only for cases
14641431
# where it would add value to override the default exception
14651432
# message string.
1466-
buffer = fcntl.ioctl(device_fd.fileno(), request_code, buffer_size)
1433+
buffer = fcntl.ioctl(device_fd.fileno(), request_code, b'\x00' * buffer_size)
14671434

14681435
# Unpack the raw result from the ioctl call into a familiar
14691436
# python data type according to the ``fmt`` rules.
@@ -1474,6 +1441,71 @@ def query_device_capacity(device_fd: IO[Any]) -> Byte:
14741441
return Byte(platform_params['func'](results))
14751442

14761443

1444+
class Capacity(NamedTuple):
1445+
"""Capacity of a filesystem volume returned by :func:`query_capacity`."""
1446+
total: 'Bitmath'
1447+
used: 'Bitmath'
1448+
free: 'Bitmath'
1449+
1450+
1451+
# Matches a bare drive letter: "C", "c", "C:", "c:" — nothing else.
1452+
_DRIVE_LETTER_RE = re.compile(r'^[A-Za-z]:?$')
1453+
1454+
1455+
def query_capacity(path: Union[str, os.PathLike], bestprefix: bool = True,
1456+
system: int = NIST) -> Capacity:
1457+
"""Return the total, used, and free capacity of the volume at ``path``.
1458+
1459+
This is the recommended API for querying volume or mount-point size. It
1460+
works cross-platform without elevated privileges.
1461+
1462+
:param path: A path on the filesystem volume to query. On Windows, a
1463+
bare drive letter (``"C"``, ``"C:"``) is normalized to ``"C:\\"``.
1464+
:param bool bestprefix: When ``True`` (default), each field of the
1465+
returned :class:`Capacity` is already normalized via
1466+
:meth:`~bitmath.Bitmath.best_prefix` for human-readable output.
1467+
When ``False``, each field is a raw :class:`bitmath.Byte`.
1468+
:param int system: Unit system to use when ``bestprefix`` is ``True``.
1469+
Either :data:`bitmath.NIST` (default, binary prefixes like ``GiB``)
1470+
or :data:`bitmath.SI` (decimal prefixes like ``GB``). Ignored when
1471+
``bestprefix`` is ``False``.
1472+
1473+
:return: A :class:`Capacity` NamedTuple with ``total``, ``used``, and
1474+
``free`` fields, each a :class:`bitmath.Bitmath` instance.
1475+
1476+
:raises FileNotFoundError: if ``path`` does not exist.
1477+
:raises PermissionError: if the process lacks access to query ``path``.
1478+
1479+
Example — attribute access (human-readable by default)::
1480+
1481+
cap = bitmath.query_capacity("/")
1482+
print(cap.total) # e.g. 465.762 GiB
1483+
1484+
Example — tuple unpacking::
1485+
1486+
total, used, free = bitmath.query_capacity("/")
1487+
1488+
Example — raw bytes and SI prefixes::
1489+
1490+
cap_raw = bitmath.query_capacity("/", bestprefix=False)
1491+
cap_si = bitmath.query_capacity("/", system=bitmath.SI)
1492+
"""
1493+
normalized: Union[str, os.PathLike] = path
1494+
if os.name == 'nt':
1495+
s = str(path).upper()
1496+
if _DRIVE_LETTER_RE.match(s):
1497+
normalized = s.rstrip(':') + ':\\'
1498+
usage = shutil.disk_usage(normalized)
1499+
total, used, free = Byte(usage.total), Byte(usage.used), Byte(usage.free)
1500+
if bestprefix:
1501+
return Capacity(
1502+
total.best_prefix(system=system),
1503+
used.best_prefix(system=system),
1504+
free.best_prefix(system=system),
1505+
)
1506+
return Capacity(total, used, free)
1507+
1508+
14771509
def getsize(path: str, bestprefix: bool = True, system: int = NIST) -> Bitmath:
14781510
"""Return a bitmath instance in the best human-readable representation
14791511
of the file size at `path`. Optionally, provide a preferred unit

0 commit comments

Comments
 (0)