rawblock-io 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.
@@ -0,0 +1,41 @@
1
+ """Raw block device I/O with automatic strategy fallback.
2
+
3
+ Provides a pluggable I/O strategy chain for reading/writing raw block
4
+ devices and disk image files, along with cross-platform device and
5
+ mount-point resolution.
6
+
7
+ Layers (one file per layer):
8
+ ``_resolve`` — Block device / mount point resolution
9
+ ``_strategies`` — Pluggable I/O strategy base class & helpers
10
+ ``_direct_io`` — ``DirectIOStrategy``
11
+ ``_backing_file`` — ``BackingFileStrategy``
12
+ ``_dd`` — ``DDStrategy``
13
+ ``_io`` — ``RawBlockIO`` (strategy chain)
14
+ """
15
+
16
+ from rawblock_io._strategies import (
17
+ IOStrategy,
18
+ DirectIOStrategy,
19
+ BackingFileStrategy,
20
+ DDStrategy,
21
+ BLOCK_SIZE,
22
+ _block_align,
23
+ _try_pread,
24
+ _try_pwrite,
25
+ )
26
+ from rawblock_io._io import RawBlockIO
27
+ from rawblock_io._resolve import resolve_device, resolve_mount_point
28
+
29
+ __all__ = [
30
+ 'IOStrategy',
31
+ 'DirectIOStrategy',
32
+ 'BackingFileStrategy',
33
+ 'DDStrategy',
34
+ 'BLOCK_SIZE',
35
+ '_block_align',
36
+ '_try_pread',
37
+ '_try_pwrite',
38
+ 'RawBlockIO',
39
+ 'resolve_device',
40
+ 'resolve_mount_point',
41
+ ]
@@ -0,0 +1,84 @@
1
+ """Backing-file strategy — reads/writes via the loop-device backing file."""
2
+
3
+ import os
4
+ import platform
5
+ import plistlib
6
+ import subprocess
7
+
8
+ from rawblock_io._strategies import IOStrategy, _try_pread, _try_pwrite
9
+
10
+
11
+ class BackingFileStrategy(IOStrategy):
12
+ """Resolve the loop-device backing file and operate on that.
13
+
14
+ When the *device* is a loop device (e.g. ``/dev/loop0``) this
15
+ reads/writes the underlying backing file directly, bypassing
16
+ the kernel block layer entirely.
17
+
18
+ On macOS resolves via ``hdiutil info -plist`` instead.
19
+ """
20
+
21
+ def __init__(self):
22
+ self._backing_cache: dict[str, str | None] = {}
23
+ self._is_darwin = platform.system() == 'Darwin'
24
+
25
+ def _resolve(self, device: str) -> str | None:
26
+ if device not in self._backing_cache:
27
+ if self._is_darwin:
28
+ self._backing_cache[device] = self._resolve_darwin(device)
29
+ else:
30
+ self._backing_cache[device] = self._resolve_linux(device)
31
+ return self._backing_cache[device]
32
+
33
+ def _resolve_linux(self, device: str) -> str | None:
34
+ dev_name = device.lstrip('/dev/')
35
+ for cmd in (
36
+ ['cat', f'/sys/block/{dev_name}/loop/backing_file'],
37
+ ['sudo', 'cat', f'/sys/block/{dev_name}/loop/backing_file'],
38
+ ['losetup', '-n', '-O', 'BACK-FILE', device],
39
+ ['sudo', 'losetup', '-n', '-O', 'BACK-FILE', device],
40
+ ):
41
+ try:
42
+ r = subprocess.run(
43
+ cmd, capture_output=True, text=True, timeout=5)
44
+ if r.returncode == 0:
45
+ return r.stdout.strip() or None
46
+ except Exception:
47
+ pass
48
+ return None
49
+
50
+ def _resolve_darwin(self, device: str) -> str | None:
51
+ try:
52
+ r = subprocess.run(
53
+ ['hdiutil', 'info', '-plist'],
54
+ capture_output=True, text=True, timeout=10)
55
+ if r.returncode != 0:
56
+ return None
57
+ plist = plistlib.loads(r.stdout.encode())
58
+ for img in plist.get('images', []):
59
+ if not isinstance(img, dict):
60
+ continue
61
+ for ent in img.get('system-entities', []):
62
+ if isinstance(ent, dict) and ent.get('dev-entry') == device:
63
+ return img.get('image-path')
64
+ except Exception:
65
+ pass
66
+ return None
67
+
68
+ def read(self, device: str, offset: int, size: int) -> bytes | None:
69
+ backing = self._resolve(device)
70
+ if backing and os.access(backing, os.R_OK):
71
+ return _try_pread(backing, offset, size)
72
+ return None
73
+
74
+ def write(self, device: str, offset: int, data: bytes) -> bool:
75
+ backing = self._resolve(device)
76
+ if backing and os.access(backing, os.W_OK):
77
+ return _try_pwrite(backing, offset, data)
78
+ return False
79
+
80
+ def clear_cache(self, device: str | None = None):
81
+ if device is None:
82
+ self._backing_cache.clear()
83
+ else:
84
+ self._backing_cache.pop(device, None)
rawblock_io/_dd.py ADDED
@@ -0,0 +1,59 @@
1
+ """DD strategy — falls back to ``sudo dd`` for raw block device I/O."""
2
+
3
+ import subprocess
4
+ import tempfile
5
+
6
+ from rawblock_io._strategies import IOStrategy, BLOCK_SIZE, _block_align
7
+
8
+
9
+ class DDStrategy(IOStrategy):
10
+ """Fall back to ``sudo dd`` for read/write on physical block devices.
11
+
12
+ Used when the process lacks direct access to the device and must
13
+ elevate privileges via ``sudo``.
14
+
15
+ I/O is always done in multiples of ``BLOCK_SIZE`` (512 bytes) to
16
+ support platforms where the device requires sector-aligned access
17
+ (e.g. macOS ``/dev/rdisk*`` raw devices).
18
+ """
19
+
20
+ def read(self, device: str, offset: int, size: int) -> bytes | None:
21
+ try:
22
+ aligned_off, total, skip = _block_align(offset, size)
23
+ cmd = ['sudo', 'dd', f'if={device}', f'bs={BLOCK_SIZE}',
24
+ f'skip={aligned_off // BLOCK_SIZE}',
25
+ f'count={total // BLOCK_SIZE}']
26
+ r = subprocess.run(cmd, stdout=subprocess.PIPE,
27
+ stderr=subprocess.DEVNULL)
28
+ if r.returncode != 0:
29
+ return None
30
+ return r.stdout[skip:skip + size]
31
+ except FileNotFoundError:
32
+ return None
33
+
34
+ def write(self, device: str, offset: int, data: bytes) -> bool:
35
+ try:
36
+ aligned_off, total, skip = _block_align(offset, len(data))
37
+ if skip == 0 and total == len(data):
38
+ return self._write_blocks(device, aligned_off, data)
39
+ buf = self.read(device, aligned_off, total)
40
+ if buf is None or len(buf) < total:
41
+ return False
42
+ buf = bytearray(buf)
43
+ buf[skip:skip + len(data)] = data
44
+ return self._write_blocks(device, aligned_off, bytes(buf))
45
+ except FileNotFoundError:
46
+ return False
47
+
48
+ def _write_blocks(self, device: str, offset: int, data: bytes) -> bool:
49
+ with tempfile.NamedTemporaryFile() as tf:
50
+ tf.write(data)
51
+ tf.flush()
52
+ cmd = ['sudo', 'dd', f'if={tf.name}', f'of={device}',
53
+ f'bs={BLOCK_SIZE}',
54
+ f'seek={offset // BLOCK_SIZE}',
55
+ f'count={len(data) // BLOCK_SIZE}',
56
+ 'conv=fsync']
57
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE,
58
+ stderr=subprocess.DEVNULL)
59
+ return True
@@ -0,0 +1,17 @@
1
+ """Direct I/O strategy — reads/writes via ``os.pread``/``os.pwrite``."""
2
+
3
+ from rawblock_io._strategies import IOStrategy, _try_pread, _try_pwrite
4
+
5
+
6
+ class DirectIOStrategy(IOStrategy):
7
+ """Read/write directly on *device* via ``os.pread``/``os.pwrite``.
8
+
9
+ Works for regular files (e.g. disk images) and any block device
10
+ the process has permission to access.
11
+ """
12
+
13
+ def read(self, device: str, offset: int, size: int) -> bytes | None:
14
+ return _try_pread(device, offset, size)
15
+
16
+ def write(self, device: str, offset: int, data: bytes) -> bool:
17
+ return _try_pwrite(device, offset, data)
rawblock_io/_io.py ADDED
@@ -0,0 +1,43 @@
1
+ """Raw block I/O — delegates to a chain of pluggable strategies."""
2
+
3
+ from rawblock_io._strategies import (
4
+ IOStrategy,
5
+ DirectIOStrategy,
6
+ BackingFileStrategy,
7
+ DDStrategy,
8
+ )
9
+
10
+
11
+ def _default_strategies() -> list[IOStrategy]:
12
+ return [DirectIOStrategy(), BackingFileStrategy(), DDStrategy()]
13
+
14
+
15
+ class RawBlockIO:
16
+ """Raw block I/O — delegates to a chain of pluggable strategies.
17
+
18
+ Parameters
19
+ ----------
20
+ strategies
21
+ Ordered list of ``IOStrategy`` instances. Defaults to
22
+ ``[DirectIOStrategy(), BackingFileStrategy(), DDStrategy()]``
23
+ on Linux; ``[DirectIOStrategy(), DDStrategy()]`` on macOS.
24
+ """
25
+
26
+ def __init__(self, strategies: list[IOStrategy] | None = None):
27
+ self._strategies = strategies or _default_strategies()
28
+
29
+ def clear_cache(self, device: str | None = None):
30
+ for s in self._strategies:
31
+ s.clear_cache(device)
32
+
33
+ def read(self, device: str, offset: int, size: int) -> bytes:
34
+ for s in self._strategies:
35
+ result = s.read(device, offset, size)
36
+ if result is not None:
37
+ return result
38
+ return b''
39
+
40
+ def write(self, device: str, offset: int, data: bytes):
41
+ for s in self._strategies:
42
+ if s.write(device, offset, data):
43
+ return
@@ -0,0 +1,57 @@
1
+ """Platform-specific helpers to resolve a block device and mount point from a
2
+ file path.
3
+
4
+ Dispatches to an OS-specific submodule (``_resolve_linux`` or
5
+ ``_resolve_darwin``) at module-load time and re-exports the public API.
6
+ """
7
+
8
+ import importlib
9
+ import platform
10
+ import subprocess
11
+
12
+
13
+ SYSTEM = platform.system()
14
+
15
+
16
+ def _df_output(path: str) -> tuple[str, str, str] | None:
17
+ """Return ``(device, mount_point, fstype)`` for *path* via ``df``."""
18
+ try:
19
+ if SYSTEM == 'Darwin':
20
+ r = subprocess.run(
21
+ ['df', str(path)],
22
+ capture_output=True, text=True, timeout=5)
23
+ if r.returncode != 0:
24
+ return None
25
+ lines = r.stdout.strip().splitlines()
26
+ if len(lines) < 2:
27
+ return None
28
+ parts = lines[1].split()
29
+ if len(parts) < 3:
30
+ return None
31
+ device = parts[0]
32
+ mount_point = parts[-1]
33
+ stat_r = subprocess.run(
34
+ ['stat', '-f', '%T', str(path)],
35
+ capture_output=True, text=True, timeout=5)
36
+ fstype = stat_r.stdout.strip() if stat_r.returncode == 0 else ''
37
+ return device, mount_point, fstype
38
+ else:
39
+ r = subprocess.run(
40
+ ['df', '--output=fstype,target,source', str(path)],
41
+ capture_output=True, text=True, timeout=5)
42
+ if r.returncode != 0:
43
+ return None
44
+ lines = r.stdout.strip().splitlines()
45
+ if len(lines) < 2:
46
+ return None
47
+ cols = lines[1].split(None, 2)
48
+ if len(cols) < 3:
49
+ return None
50
+ return cols[2], cols[1], cols[0]
51
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
52
+ return None
53
+
54
+
55
+ _mod = importlib.import_module(f'._resolve_{SYSTEM.lower()}', __package__)
56
+ resolve_device = _mod.resolve_device
57
+ resolve_mount_point = _mod.resolve_mount_point
@@ -0,0 +1,42 @@
1
+ """macOS-specific device/mount resolution."""
2
+
3
+ import os
4
+ import plistlib
5
+ import subprocess
6
+
7
+ from rawblock_io._resolve import _df_output
8
+
9
+
10
+ def resolve_device(path: str) -> str | None:
11
+ info = _df_output(path)
12
+ if info:
13
+ dev = info[0]
14
+ backing = _resolve_backing_file_darwin(dev)
15
+ if backing and os.path.isfile(backing):
16
+ return backing
17
+ return dev
18
+ return None
19
+
20
+
21
+ def _resolve_backing_file_darwin(dev_entry: str) -> str | None:
22
+ try:
23
+ r = subprocess.run(
24
+ ['hdiutil', 'info', '-plist'],
25
+ capture_output=True, text=True, timeout=10)
26
+ if r.returncode != 0:
27
+ return None
28
+ plist = plistlib.loads(r.stdout.encode())
29
+ for img in plist.get('images', []):
30
+ if not isinstance(img, dict):
31
+ continue
32
+ for ent in img.get('system-entities', []):
33
+ if isinstance(ent, dict) and ent.get('dev-entry') == dev_entry:
34
+ return img.get('image-path')
35
+ except Exception:
36
+ pass
37
+ return None
38
+
39
+
40
+ def resolve_mount_point(path: str) -> str | None:
41
+ info = _df_output(path)
42
+ return info[1] if info else None
@@ -0,0 +1,30 @@
1
+ """Linux-specific device/mount resolution."""
2
+
3
+ import os
4
+ import subprocess
5
+
6
+
7
+ def resolve_device(path: str) -> str | None:
8
+ st = os.stat(path)
9
+ major = os.major(st.st_dev)
10
+ minor = os.minor(st.st_dev)
11
+ with open('/proc/partitions') as f:
12
+ for line in f:
13
+ parts = line.split()
14
+ if len(parts) == 4 and parts[0].isdigit():
15
+ if int(parts[0]) == major and int(parts[1]) == minor:
16
+ return f'/dev/{parts[3]}'
17
+ try:
18
+ link = os.readlink(f'/sys/dev/block/{major}:{minor}')
19
+ return os.path.join('/dev', os.path.basename(link))
20
+ except OSError:
21
+ return None
22
+
23
+
24
+ def resolve_mount_point(path: str) -> str | None:
25
+ r = subprocess.run(
26
+ ['findmnt', '-n', '-o', 'TARGET', '--target', str(path)],
27
+ capture_output=True, text=True, timeout=5)
28
+ if r.returncode == 0 and r.stdout.strip():
29
+ return r.stdout.strip()
30
+ return None
@@ -0,0 +1,80 @@
1
+ """Pluggable I/O strategy classes for raw block I/O.
2
+
3
+ Each strategy implements a different mechanism for reading/writing raw
4
+ blocks on a device path. Strategies are tried in order until one succeeds.
5
+
6
+ The base class and shared helpers live here; each concrete strategy is
7
+ defined in its own submodule and re-exported below.
8
+ """
9
+
10
+ import os
11
+ from abc import ABC, abstractmethod
12
+
13
+
14
+ class IOStrategy(ABC):
15
+ """Pluggable read/write strategy for raw block I/O."""
16
+
17
+ @abstractmethod
18
+ def read(self, device: str, offset: int, size: int) -> bytes | None:
19
+ """Read *size* bytes from *device* at *offset*.
20
+
21
+ Return bytes on success, or ``None`` to fall through to the
22
+ next strategy in the chain.
23
+ """
24
+
25
+ @abstractmethod
26
+ def write(self, device: str, offset: int, data: bytes) -> bool | None:
27
+ """Write *data* to *device* at *offset*.
28
+
29
+ Return ``True`` when handled, or ``None``/``False`` to fall
30
+ through to the next strategy.
31
+ """
32
+
33
+ def clear_cache(self, device: str | None = None):
34
+ """Drop cached state for *device* (or all if ``None``)."""
35
+
36
+
37
+ def _try_pread(path: str, offset: int, size: int) -> bytes | None:
38
+ try:
39
+ fd = os.open(path, os.O_RDONLY)
40
+ try:
41
+ return os.pread(fd, size, offset)
42
+ finally:
43
+ os.close(fd)
44
+ except OSError:
45
+ return None
46
+
47
+
48
+ def _try_pwrite(path: str, offset: int, data: bytes) -> bool:
49
+ try:
50
+ fd = os.open(path, os.O_WRONLY)
51
+ try:
52
+ n = os.pwrite(fd, data, offset)
53
+ assert n == len(data)
54
+ os.fsync(fd)
55
+ finally:
56
+ os.close(fd)
57
+ return True
58
+ except OSError:
59
+ return False
60
+
61
+
62
+ BLOCK_SIZE = 512
63
+
64
+
65
+ def _block_align(offset: int, size: int) -> tuple[int, int, int]:
66
+ """Return ``(aligned_offset, total_bytes, prefix_skip)``.
67
+
68
+ Rounds *offset* down and *size* up so the resulting region is aligned
69
+ to ``BLOCK_SIZE``. *prefix_skip* is the number of bytes before the
70
+ caller's data in the first block.
71
+ """
72
+ aligned = (offset // BLOCK_SIZE) * BLOCK_SIZE
73
+ end = offset + size
74
+ aligned_end = ((end + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
75
+ return aligned, aligned_end - aligned, offset - aligned
76
+
77
+
78
+ from rawblock_io._direct_io import DirectIOStrategy # noqa: E402
79
+ from rawblock_io._backing_file import BackingFileStrategy # noqa: E402
80
+ from rawblock_io._dd import DDStrategy # noqa: E402
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: rawblock-io
3
+ Version: 0.1.0
4
+ Summary: Raw block device I/O with automatic strategy fallback and cross-platform device/mount resolution
5
+ Author-email: Michael Banucu <michael.banucu@googlemail.com>
6
+ License: GPL-3.0-only
7
+ Project-URL: Homepage, https://github.com/MBanucu/rawblock-io
8
+ Project-URL: Repository, https://github.com/MBanucu/rawblock-io
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: System :: Filesystems
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+
23
+ # rawblock-io
24
+
25
+ Raw block device I/O with automatic strategy fallback and cross-platform
26
+ device/mount point resolution.
27
+
28
+ ## Features
29
+
30
+ - **Pluggable I/O strategies** — tries direct access first, falls back
31
+ through loop-device backing file to `sudo dd`
32
+ - **`DirectIOStrategy`** — `os.pread`/`os.pwrite` on regular files and
33
+ accessible block devices
34
+ - **`BackingFileStrategy`** — resolves loop-device backing files (`/sys/block`,
35
+ `losetup`, or `hdiutil` on macOS)
36
+ - **`DDStrategy`** — `sudo dd` fallback for physical block devices
37
+ - **`resolve_device`** — find the underlying block device for any file path
38
+ (Linux `/proc/partitions` + `/sys/dev/block`, macOS `hdiutil`)
39
+ - **`resolve_mount_point`** — find the mount point for any file path
40
+ (Linux `findmnt`, macOS `df`)
41
+
42
+ ## Quick start
43
+
44
+ ```python
45
+ from rawblock_io import RawBlockIO, resolve_device
46
+
47
+ io = RawBlockIO()
48
+ device = resolve_device('/some/file')
49
+ data = io.read(device, 0, 512) # read first sector
50
+ ```
51
+
52
+ ## License
53
+
54
+ GPL-3.0-only
@@ -0,0 +1,13 @@
1
+ rawblock_io/__init__.py,sha256=r_LfYlw9YZ2GHCFDe_BWpjUVt3_ZGAxqfxNuTTi3rg8,1106
2
+ rawblock_io/_backing_file.py,sha256=PnNvlAHeRycXauUeprNTlFjEbFid6o_efcwg3F_FWeM,3076
3
+ rawblock_io/_dd.py,sha256=eCRqhQb_E_Hv1Nh_-3rMAEaYML7FHJyTW9zPFGQaUTs,2379
4
+ rawblock_io/_direct_io.py,sha256=tKTcqJL_HhqgK8pRbgHMldmqnNT_yf-FksmVLoOw0_8,616
5
+ rawblock_io/_io.py,sha256=Lyrrt25iwM2POZghyEK155H3qdgFArwmbLow8E3xT2I,1309
6
+ rawblock_io/_resolve.py,sha256=eYkz7eNnvmyp2kqNsRnmit26uF1j32e8mO1jhYkLly4,1950
7
+ rawblock_io/_resolve_darwin.py,sha256=001wlHUtp5zpaC3vKuAzMY9vcEjO6S1WDuYW4Q1LDOo,1177
8
+ rawblock_io/_resolve_linux.py,sha256=MTkjYHsRYL9SabyP1Ke5RlXARdzajaHnz5Q9B_u7Um8,935
9
+ rawblock_io/_strategies.py,sha256=cMZ2WirkF7spLGF3aJrqzxJ6sjuZ2C-xdF4iCoBQyq8,2442
10
+ rawblock_io-0.1.0.dist-info/METADATA,sha256=uUNfxI0Y3KA8dReD5lPgoKC97ji6F5AjsVaH00n4hng,2022
11
+ rawblock_io-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ rawblock_io-0.1.0.dist-info/top_level.txt,sha256=KhrVE6ZaJnwEW2wS1JoMZV751u2DzR1QV26qM7QU_e8,12
13
+ rawblock_io-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ rawblock_io