vdiskprobe 1.0.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.
Files changed (38) hide show
  1. vdiskprobe-1.0.0/CHANGELOG.md +12 -0
  2. vdiskprobe-1.0.0/LICENCE +21 -0
  3. vdiskprobe-1.0.0/PKG-INFO +271 -0
  4. vdiskprobe-1.0.0/README.md +243 -0
  5. vdiskprobe-1.0.0/pyproject.toml +60 -0
  6. vdiskprobe-1.0.0/setup.cfg +4 -0
  7. vdiskprobe-1.0.0/tests/test_adapter_edges.py +154 -0
  8. vdiskprobe-1.0.0/tests/test_api.py +118 -0
  9. vdiskprobe-1.0.0/tests/test_cli.py +183 -0
  10. vdiskprobe-1.0.0/tests/test_coverage_paths.py +87 -0
  11. vdiskprobe-1.0.0/tests/test_detection.py +298 -0
  12. vdiskprobe-1.0.0/tests/test_format_helpers.py +153 -0
  13. vdiskprobe-1.0.0/tests/test_http_source.py +311 -0
  14. vdiskprobe-1.0.0/tests/test_metadata_edges.py +292 -0
  15. vdiskprobe-1.0.0/tests/test_packaging.py +118 -0
  16. vdiskprobe-1.0.0/tests/test_registry.py +85 -0
  17. vdiskprobe-1.0.0/tests/test_review_regressions.py +170 -0
  18. vdiskprobe-1.0.0/tests/test_source.py +160 -0
  19. vdiskprobe-1.0.0/vdiskprobe/__init__.py +29 -0
  20. vdiskprobe-1.0.0/vdiskprobe/__main__.py +9 -0
  21. vdiskprobe-1.0.0/vdiskprobe/api.py +119 -0
  22. vdiskprobe-1.0.0/vdiskprobe/cli.py +195 -0
  23. vdiskprobe-1.0.0/vdiskprobe/detectors/__init__.py +12 -0
  24. vdiskprobe-1.0.0/vdiskprobe/detectors/_format_helpers.py +296 -0
  25. vdiskprobe-1.0.0/vdiskprobe/detectors/base.py +200 -0
  26. vdiskprobe-1.0.0/vdiskprobe/detectors/formats.py +372 -0
  27. vdiskprobe-1.0.0/vdiskprobe/errors.py +20 -0
  28. vdiskprobe-1.0.0/vdiskprobe/http_source.py +324 -0
  29. vdiskprobe-1.0.0/vdiskprobe/info.py +149 -0
  30. vdiskprobe-1.0.0/vdiskprobe/log.py +95 -0
  31. vdiskprobe-1.0.0/vdiskprobe/source.py +284 -0
  32. vdiskprobe-1.0.0/vdiskprobe/version.py +8 -0
  33. vdiskprobe-1.0.0/vdiskprobe.egg-info/PKG-INFO +271 -0
  34. vdiskprobe-1.0.0/vdiskprobe.egg-info/SOURCES.txt +36 -0
  35. vdiskprobe-1.0.0/vdiskprobe.egg-info/dependency_links.txt +1 -0
  36. vdiskprobe-1.0.0/vdiskprobe.egg-info/entry_points.txt +2 -0
  37. vdiskprobe-1.0.0/vdiskprobe.egg-info/requires.txt +7 -0
  38. vdiskprobe-1.0.0/vdiskprobe.egg-info/top_level.txt +1 -0
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 — 2026-10-16
4
+
5
+ - Introduce a dependency-free Python library and CLI for CPython 3.11–3.15, distributed as a wheel and source archive.
6
+ - Detect 13 virtual disk image formats from local paths, binary streams, custom random-access readers, and HTTP/HTTPS URLs: qcow, qcow2, qed, vmdk, vdi, vpc (vhd), vhdx, luks, bochs, parallels, cloop, dmg, and raw as a low-confidence fallback.
7
+ - Report available logical, allocated, virtual, and cluster sizes, format-specific metadata, and detection confidence through the Python API and text or JSON CLI output. Leave unavailable or incomplete metadata unknown.
8
+ - Provide an extensible detector registry and cached, bounded byte reads. Preserve caller-owned streams and seek positions, including when readers return short data.
9
+ - Support bounded HTTP range requests, transient-failure retries, and verified HTTPS connections. Continue header recognition when ranges are refused, reject contradictory range metadata, and report source failures without guessing a raw format or downloading an entire image.
10
+ - Handle truncated headers, oversized decimal metadata, and incomplete VMDK descriptors without reporting partial capacities. Support legacy VDI layouts and VHD dynamic-header offsets while retaining format recognition when optional metadata is unavailable.
11
+ - Expose both `vdiskprobe` and `python -m vdiskprobe`, with format listing, version reporting, logging controls, and defined exit statuses. Read the runtime version from installed package metadata.
12
+ - Provide opt-in logging configuration without configuring application logging, and avoid duplicate package messages when propagation is enabled.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Jobz
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,271 @@
1
+ Metadata-Version: 2.4
2
+ Name: vdiskprobe
3
+ Version: 1.0.0
4
+ Summary: A lightweight Python library and CLI for detecting virtual disk image formats and reading size metadata from local files, custom readers, and HTTP/HTTPS URLs.
5
+ Author-email: SteveJobz@isarvin.com
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/ArvinZJC/VDiskProbe
8
+ Project-URL: Issues, https://github.com/ArvinZJC/VDiskProbe/issues
9
+ Project-URL: Changelog, https://github.com/ArvinZJC/VDiskProbe/blob/main/CHANGELOG.md
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Topic :: System :: Filesystems
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENCE
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8; extra == "dev"
23
+ Requires-Dist: pytest-cov>=6; extra == "dev"
24
+ Requires-Dist: black>=26.5.0; extra == "dev"
25
+ Requires-Dist: build>=1.2; extra == "dev"
26
+ Requires-Dist: setuptools>=77; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # VDiskProbe
30
+
31
+ ![GitHub Release](https://img.shields.io/github/v/release/ArvinZJC/VDiskProbe)
32
+ [![PyPI version](https://img.shields.io/pypi/v/vdiskprobe)](https://pypi.org/project/vdiskprobe/)
33
+ [![Python versions](https://img.shields.io/pypi/pyversions/vdiskprobe)](https://pypi.org/project/vdiskprobe/)
34
+ ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/ArvinZJC/VDiskProbe)
35
+ [![GitHub License](https://img.shields.io/github/license/ArvinZJC/VDiskProbe?label=licence)](./LICENCE)
36
+
37
+ VDiskProbe is a dependency-free Python library and CLI for detecting virtual disk image formats and reading available size metadata. It accepts local paths, binary file objects, custom random-access readers, and HTTP/HTTPS URLs.
38
+
39
+ VDiskProbe supports Python 3.11–3.15. Detection reads headers and metadata; it does not mount, convert, decrypt, or validate an entire image.
40
+
41
+ ## Architecture
42
+
43
+ The distribution, import package, and command are named `vdiskprobe`. Public APIs are re-exported from `vdiskprobe/__init__.py` and follow this flow:
44
+
45
+ ```text
46
+ reader/path -> ByteSource -> DetectorRegistry -> ImageInfo
47
+ ```
48
+
49
+ `ByteSource` adapts readers, `DetectorRegistry` selects a detector, and `ImageInfo` holds its result. Detector classes live in `vdiskprobe/detectors/formats.py`; binary parsing helpers live in `vdiskprobe/detectors/_format_helpers.py`.
50
+
51
+ ## Installation
52
+
53
+ Install from a source checkout using a supported Python interpreter:
54
+
55
+ ```bash
56
+ python -m pip install .
57
+ ```
58
+
59
+ A built wheel or source archive can also be installed with `python -m pip install <artefact-path>`.
60
+
61
+ ## Formats and metadata
62
+
63
+ Built-in formats, in detection order: `qcow`, `qcow2`, `qed`, `vmdk`, `vdi`, `vpc` (alias `vhd`), `vhdx`, `luks`, `bochs`, `parallels`, `cloop`, `dmg`, and `raw`.
64
+
65
+ Results include the format, description, confidence, detector, aliases, source name, format-specific details, and these optional byte counts:
66
+
67
+ | Field | Meaning |
68
+ | -------------- | ----------------------------------------------------- |
69
+ | `file_size` | Logical source length. |
70
+ | `disk_size` | Host filesystem allocation when available. |
71
+ | `virtual_size` | Guest-visible size derived from image metadata. |
72
+ | `cluster_size` | Allocation unit or block size recorded by the format. |
73
+
74
+ Unknown values are `None` in Python and `null` in JSON. On systems without filesystem block counts, `disk_size` falls back to logical file size. The content length of a remote image does not indicate how much disk space it occupies on the server.
75
+
76
+ `raw` is a fallback with confidence `0.1`, meaning no recognised signature matched. It is not proof of a raw image. VMDK descriptor and cloop script heuristics use `0.95`; other recognised signatures use `1.0`. VMDK extent counts beyond the interpreter's configured decimal-conversion limit (or its default limit when disabled) yield an unknown virtual size rather than a partial total. Descriptor capacity is also unknown when the source is known to extend beyond the bytes read, or a descriptor of unknown length fills the 64 KiB inspection window. A shorter read from a source of unknown length requires an end-of-source probe before reporting capacity. Recognition can succeed on a short header without optional metadata and does not prove that the image is complete or uncorrupted. The `read_only` field describes format metadata; VDiskProbe itself only reads.
77
+
78
+ ## Command line
79
+
80
+ ```bash
81
+ vdiskprobe disk.qcow2
82
+ vdiskprobe disk.qcow2 --json
83
+ python -m vdiskprobe disk.qcow2 --json
84
+ vdiskprobe --list-formats
85
+ vdiskprobe --version
86
+ ```
87
+
88
+ A path may also be an HTTP/HTTPS URL. `-v` enables info logging, `-vv` enables debug logging, and `-q` selects errors only and takes precedence over verbosity. Results go to stdout and diagnostics to stderr. Exit statuses are `0` for success, `1` for source errors, and `2` for invalid arguments. `--list-formats` takes precedence over inspection and `--json`.
89
+
90
+ ## Python API
91
+
92
+ ```python
93
+ from pathlib import Path
94
+ from vdiskprobe import detect, detect_path
95
+
96
+ info = detect_path(Path("disk.qcow2"))
97
+ print(info.format, info.virtual_size)
98
+ print(info.to_dict())
99
+
100
+ with open("disk.qcow2", "rb") as stream:
101
+ info = detect(stream, name="disk.qcow2")
102
+ ```
103
+
104
+ `detect` and `inspect_source` accept a reader or an existing `ByteSource`, plus an optional `registry` and `name`. `detect_path` accepts a text path, `PathLike[str]`, or HTTP/HTTPS URL; `detect_url` accepts only HTTP/HTTPS URLs. Use `io.BytesIO(payload)` for in-memory bytes.
105
+
106
+ Seekable readers retain their cursor. Caller-owned readers remain open; files and HTTP responses opened by the library are closed after use. A reader should expose stable contents during inspection; shared-reader thread safety and concurrent source mutation are not guaranteed.
107
+
108
+ ### Custom readers
109
+
110
+ Provide `read_at(offset, size)`, `pread(offset, size)`, `read_range(offset, size)`, `seek`/`tell`/`read(size)`, or `read(offset, size)`, in that precedence order. Here `pread` takes the offset first, unlike `os.pread`.
111
+
112
+ ```python
113
+ from vdiskprobe import detect
114
+
115
+ class RangeReader:
116
+ def __init__(self, payload):
117
+ self.payload = payload
118
+
119
+ def read(self, offset, size):
120
+ return self.payload[offset:offset + size]
121
+
122
+ def size(self):
123
+ return len(self.payload)
124
+
125
+ info = detect(RangeReader(b"QFI\xfb\x00\x00\x00\x03"))
126
+ assert info.format == "qcow2"
127
+ ```
128
+
129
+ Optional size sources are `size` or `length` attributes or methods, `len(reader)`, a file descriptor, or seeking to the end of the source. `ByteSource.read` rejects negative offsets and sizes and returns at most the requested bytes. Reads up to 64 KiB can reuse cached prefixes and exact ranges. A short read caches only the bytes returned; it does not establish the end of the source.
130
+
131
+ Binary readers are preferred. At the adapter boundary, `bytearray` and `memoryview` objects are converted to `bytes`, `None` means an empty read, and text is encoded as Latin-1 without replacing unencodable characters. `tobytes()`/`tostring()` providers and conversion of other objects to text are also supported. Unencodable text raises an encoding error. Backend authentication, application-level retries, and SDK integration remain the caller's responsibility.
132
+
133
+ ### HTTP and HTTPS
134
+
135
+ ```python
136
+ from vdiskprobe import detect_url
137
+
138
+ info = detect_url("https://example.com/disk.qcow2")
139
+ print(info.format, info.virtual_size)
140
+ ```
141
+
142
+ URL paths must be percent-encoded where necessary. URL encoding failures raise `RemoteSourceError` without retrying.
143
+
144
+ For explicit timeout and retry settings, construct `HttpRangeReader` from `vdiskprobe.http_source` and pass it to `detect(reader)`.
145
+
146
+ HTTP requests use bounded byte ranges and identity encoding. Three retries allow at most four attempts for a transient failure, with exponential backoff beginning at 0.25 seconds. Remote size discovery uses a one-byte GET before falling back to HEAD. A supplied byte interval that contradicts the request raises `RemoteSourceError`; absent range metadata and unknown totals retain the size-discovery fallbacks.
147
+
148
+ A server that ignores ranges can still support a header match at offset zero. A refused range lets detection continue to later signatures; unavailable optional metadata remains unknown. If no signature matches, the refused range raises `RangeNotSupportedError` (a `RemoteSourceError` subclass in `vdiskprobe.errors`) rather than producing a raw result. Other transport failures propagate immediately. VDiskProbe never downloads the full image to simulate random access. Footer-based detection also depends on discovering source length.
149
+
150
+ TLS certificate and hostname verification stay enabled. Trust-store discovery checks `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `SSL_CERT_DIR`, then Python/OpenSSL defaults and common system paths. Redirects and proxy settings from the environment follow the standard library’s behaviour.
151
+
152
+ ### Custom detectors, errors, and logging
153
+
154
+ ```python
155
+ from io import BytesIO
156
+ from vdiskprobe import FormatDetector, build_default_registry, detect
157
+
158
+ class ExampleDetector(FormatDetector):
159
+ format_name = "example"
160
+ description = "Example image"
161
+ priority = 5
162
+
163
+ def detect(self, source):
164
+ if source.read(0, 4) == b"EXMP":
165
+ return self.build_info(source)
166
+ return None
167
+
168
+ registry = build_default_registry()
169
+ registry.register(ExampleDetector())
170
+ info = detect(BytesIO(b"EXMP"), registry=registry)
171
+ assert info.format == "example"
172
+ ```
173
+
174
+ Lower priorities run first; equal priorities retain registration order. The first match wins. Keep raw as the final fallback when creating a registry yourself; if no detector matches, the registry raises `RuntimeError`. Source failures propagate rather than becoming a raw result; refused HTTP ranges follow the bounded detection behaviour described above.
175
+
176
+ `InvalidSourceError` and `RemoteSourceError` inherit from `VDiskProbeError`. Local I/O failures can raise `OSError`. Logging uses a `NullHandler` until configured; call `configure_logging()` to attach a stream handler. Library logging does not configure the application's root logger.
177
+
178
+ ```python
179
+ import logging
180
+ from vdiskprobe import configure_logging
181
+
182
+ configure_logging(logging.DEBUG)
183
+ ```
184
+
185
+ For applications that already configure ancestor handlers, use `configure_logging(logging.DEBUG, propagate=True)`. Enabling propagation removes the package stream handler and null handlers, whilst preserving application handlers. Repeated stream configuration reuses an existing package stream handler; it does not replace its stream. `get_logger(name)` is intended for package modules. Applications using the library should use their own `logging.getLogger(__name__)`.
186
+
187
+ ## Development
188
+
189
+ Run commands from the repository root with a supported Python interpreter. Create and activate an isolated environment, then install the development tools:
190
+
191
+ ```bash
192
+ python -m venv .venv
193
+ source .venv/bin/activate
194
+ python -m pip install -e '.[dev]'
195
+ ```
196
+
197
+ Use the activation command appropriate for your shell. Development tools belong in the `dev` extra in `pyproject.toml`.
198
+
199
+ ### Required checks
200
+
201
+ After every Python change, all four checks must pass: Black, fewer than 1000 lines per Python file, the complete test suite with 100% source statement and branch coverage, and Git whitespace validation. A failed or unavailable check means verification is incomplete.
202
+
203
+ Black reads its settings from `pyproject.toml`. Format the Python files, then check them:
204
+
205
+ ```bash
206
+ python -m black vdiskprobe tests
207
+ python -m black --check vdiskprobe tests
208
+ ```
209
+
210
+ Check every tracked or unignored Python file, including tests and scripts. This prints files with 1000 or more lines and exits with a non-zero status if any exist:
211
+
212
+ ```bash
213
+ python - <<'PYTHON'
214
+ from pathlib import Path
215
+ import subprocess
216
+
217
+ paths = subprocess.check_output([
218
+ "git", "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "*.py"
219
+ ]).decode().split("\0")
220
+ failures = []
221
+ for name in sorted(set(paths)):
222
+ if name and Path(name).is_file():
223
+ count = len(Path(name).read_text(encoding="utf-8").splitlines())
224
+ if count >= 1000:
225
+ failures.append(f"{name}: {count} lines")
226
+ print("\n".join(failures), end="\n" if failures else "")
227
+ raise SystemExit(bool(failures))
228
+ PYTHON
229
+ ```
230
+
231
+ The default pytest command enforces the coverage gate from `pyproject.toml`, measuring only `vdiskprobe`. Tests use synthetic disk headers, injected readers, and a loopback HTTP server. Keep assertions meaningful; coverage alone does not prove correctness.
232
+
233
+ ```bash
234
+ python -m pytest
235
+ git diff --check
236
+ ```
237
+
238
+ For a focused test run without the whole-package coverage gate:
239
+
240
+ ```bash
241
+ python -m pytest --no-cov tests/test_detection.py
242
+ python -m pytest --no-cov tests/test_detection.py -k qcow2
243
+ ```
244
+
245
+ Focused runs do not replace the required full-suite check. When coverage fails, inspect the missing paths in HTML or export XML for tooling:
246
+
247
+ ```bash
248
+ python -m coverage html
249
+ python -m coverage xml
250
+ ```
251
+
252
+ Open `htmlcov/index.html` for the HTML report. Both report commands retain the configured 100% threshold and can exit with a non-zero status while still writing their reports.
253
+
254
+ ### Builds and compatibility
255
+
256
+ Build a wheel and source distribution, then run the explicit packaging checks:
257
+
258
+ ```bash
259
+ python -m build
260
+ python -m pytest -m packaging -o addopts='' tests/test_packaging.py
261
+ ```
262
+
263
+ Packaging tests are excluded from the default suite. They inspect distribution contents and metadata, install both artefacts in separate clean environments, and exercise imports and both CLI entry points outside the checkout. pip must be able to obtain the build dependencies, either from a package index or a local wheel cache.
264
+
265
+ Repeat the full suite and packaging checks on each supported Python minor when changing compatibility or packaging. Support tracks stable Python releases that have not reached end of life; check the Python release lifecycle when updating the supported range. Keep syntax and dependencies compatible with the minimum version listed above.
266
+
267
+ Testing currently covers standard CPython builds. Other Python implementations and free-threaded builds remain unverified.
268
+
269
+ ### Versioning
270
+
271
+ Versions must follow PEP 440, for example `0.1.0`, `1.0.0a1`, or `1.0.0.dev1`. The single source of truth is `[project].version` in `pyproject.toml`. `vdiskprobe/version.py` reads the installed distribution metadata using `importlib.metadata`, and the package re-exports that value as `__version__`. Install the package before importing it; an editable development install is sufficient. After changing the version, rerun `python -m pip install -e '.[dev]'` to refresh editable-install metadata, or rebuild and reinstall the distribution.
@@ -0,0 +1,243 @@
1
+ # VDiskProbe
2
+
3
+ ![GitHub Release](https://img.shields.io/github/v/release/ArvinZJC/VDiskProbe)
4
+ [![PyPI version](https://img.shields.io/pypi/v/vdiskprobe)](https://pypi.org/project/vdiskprobe/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/vdiskprobe)](https://pypi.org/project/vdiskprobe/)
6
+ ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/ArvinZJC/VDiskProbe)
7
+ [![GitHub License](https://img.shields.io/github/license/ArvinZJC/VDiskProbe?label=licence)](./LICENCE)
8
+
9
+ VDiskProbe is a dependency-free Python library and CLI for detecting virtual disk image formats and reading available size metadata. It accepts local paths, binary file objects, custom random-access readers, and HTTP/HTTPS URLs.
10
+
11
+ VDiskProbe supports Python 3.11–3.15. Detection reads headers and metadata; it does not mount, convert, decrypt, or validate an entire image.
12
+
13
+ ## Architecture
14
+
15
+ The distribution, import package, and command are named `vdiskprobe`. Public APIs are re-exported from `vdiskprobe/__init__.py` and follow this flow:
16
+
17
+ ```text
18
+ reader/path -> ByteSource -> DetectorRegistry -> ImageInfo
19
+ ```
20
+
21
+ `ByteSource` adapts readers, `DetectorRegistry` selects a detector, and `ImageInfo` holds its result. Detector classes live in `vdiskprobe/detectors/formats.py`; binary parsing helpers live in `vdiskprobe/detectors/_format_helpers.py`.
22
+
23
+ ## Installation
24
+
25
+ Install from a source checkout using a supported Python interpreter:
26
+
27
+ ```bash
28
+ python -m pip install .
29
+ ```
30
+
31
+ A built wheel or source archive can also be installed with `python -m pip install <artefact-path>`.
32
+
33
+ ## Formats and metadata
34
+
35
+ Built-in formats, in detection order: `qcow`, `qcow2`, `qed`, `vmdk`, `vdi`, `vpc` (alias `vhd`), `vhdx`, `luks`, `bochs`, `parallels`, `cloop`, `dmg`, and `raw`.
36
+
37
+ Results include the format, description, confidence, detector, aliases, source name, format-specific details, and these optional byte counts:
38
+
39
+ | Field | Meaning |
40
+ | -------------- | ----------------------------------------------------- |
41
+ | `file_size` | Logical source length. |
42
+ | `disk_size` | Host filesystem allocation when available. |
43
+ | `virtual_size` | Guest-visible size derived from image metadata. |
44
+ | `cluster_size` | Allocation unit or block size recorded by the format. |
45
+
46
+ Unknown values are `None` in Python and `null` in JSON. On systems without filesystem block counts, `disk_size` falls back to logical file size. The content length of a remote image does not indicate how much disk space it occupies on the server.
47
+
48
+ `raw` is a fallback with confidence `0.1`, meaning no recognised signature matched. It is not proof of a raw image. VMDK descriptor and cloop script heuristics use `0.95`; other recognised signatures use `1.0`. VMDK extent counts beyond the interpreter's configured decimal-conversion limit (or its default limit when disabled) yield an unknown virtual size rather than a partial total. Descriptor capacity is also unknown when the source is known to extend beyond the bytes read, or a descriptor of unknown length fills the 64 KiB inspection window. A shorter read from a source of unknown length requires an end-of-source probe before reporting capacity. Recognition can succeed on a short header without optional metadata and does not prove that the image is complete or uncorrupted. The `read_only` field describes format metadata; VDiskProbe itself only reads.
49
+
50
+ ## Command line
51
+
52
+ ```bash
53
+ vdiskprobe disk.qcow2
54
+ vdiskprobe disk.qcow2 --json
55
+ python -m vdiskprobe disk.qcow2 --json
56
+ vdiskprobe --list-formats
57
+ vdiskprobe --version
58
+ ```
59
+
60
+ A path may also be an HTTP/HTTPS URL. `-v` enables info logging, `-vv` enables debug logging, and `-q` selects errors only and takes precedence over verbosity. Results go to stdout and diagnostics to stderr. Exit statuses are `0` for success, `1` for source errors, and `2` for invalid arguments. `--list-formats` takes precedence over inspection and `--json`.
61
+
62
+ ## Python API
63
+
64
+ ```python
65
+ from pathlib import Path
66
+ from vdiskprobe import detect, detect_path
67
+
68
+ info = detect_path(Path("disk.qcow2"))
69
+ print(info.format, info.virtual_size)
70
+ print(info.to_dict())
71
+
72
+ with open("disk.qcow2", "rb") as stream:
73
+ info = detect(stream, name="disk.qcow2")
74
+ ```
75
+
76
+ `detect` and `inspect_source` accept a reader or an existing `ByteSource`, plus an optional `registry` and `name`. `detect_path` accepts a text path, `PathLike[str]`, or HTTP/HTTPS URL; `detect_url` accepts only HTTP/HTTPS URLs. Use `io.BytesIO(payload)` for in-memory bytes.
77
+
78
+ Seekable readers retain their cursor. Caller-owned readers remain open; files and HTTP responses opened by the library are closed after use. A reader should expose stable contents during inspection; shared-reader thread safety and concurrent source mutation are not guaranteed.
79
+
80
+ ### Custom readers
81
+
82
+ Provide `read_at(offset, size)`, `pread(offset, size)`, `read_range(offset, size)`, `seek`/`tell`/`read(size)`, or `read(offset, size)`, in that precedence order. Here `pread` takes the offset first, unlike `os.pread`.
83
+
84
+ ```python
85
+ from vdiskprobe import detect
86
+
87
+ class RangeReader:
88
+ def __init__(self, payload):
89
+ self.payload = payload
90
+
91
+ def read(self, offset, size):
92
+ return self.payload[offset:offset + size]
93
+
94
+ def size(self):
95
+ return len(self.payload)
96
+
97
+ info = detect(RangeReader(b"QFI\xfb\x00\x00\x00\x03"))
98
+ assert info.format == "qcow2"
99
+ ```
100
+
101
+ Optional size sources are `size` or `length` attributes or methods, `len(reader)`, a file descriptor, or seeking to the end of the source. `ByteSource.read` rejects negative offsets and sizes and returns at most the requested bytes. Reads up to 64 KiB can reuse cached prefixes and exact ranges. A short read caches only the bytes returned; it does not establish the end of the source.
102
+
103
+ Binary readers are preferred. At the adapter boundary, `bytearray` and `memoryview` objects are converted to `bytes`, `None` means an empty read, and text is encoded as Latin-1 without replacing unencodable characters. `tobytes()`/`tostring()` providers and conversion of other objects to text are also supported. Unencodable text raises an encoding error. Backend authentication, application-level retries, and SDK integration remain the caller's responsibility.
104
+
105
+ ### HTTP and HTTPS
106
+
107
+ ```python
108
+ from vdiskprobe import detect_url
109
+
110
+ info = detect_url("https://example.com/disk.qcow2")
111
+ print(info.format, info.virtual_size)
112
+ ```
113
+
114
+ URL paths must be percent-encoded where necessary. URL encoding failures raise `RemoteSourceError` without retrying.
115
+
116
+ For explicit timeout and retry settings, construct `HttpRangeReader` from `vdiskprobe.http_source` and pass it to `detect(reader)`.
117
+
118
+ HTTP requests use bounded byte ranges and identity encoding. Three retries allow at most four attempts for a transient failure, with exponential backoff beginning at 0.25 seconds. Remote size discovery uses a one-byte GET before falling back to HEAD. A supplied byte interval that contradicts the request raises `RemoteSourceError`; absent range metadata and unknown totals retain the size-discovery fallbacks.
119
+
120
+ A server that ignores ranges can still support a header match at offset zero. A refused range lets detection continue to later signatures; unavailable optional metadata remains unknown. If no signature matches, the refused range raises `RangeNotSupportedError` (a `RemoteSourceError` subclass in `vdiskprobe.errors`) rather than producing a raw result. Other transport failures propagate immediately. VDiskProbe never downloads the full image to simulate random access. Footer-based detection also depends on discovering source length.
121
+
122
+ TLS certificate and hostname verification stay enabled. Trust-store discovery checks `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `SSL_CERT_DIR`, then Python/OpenSSL defaults and common system paths. Redirects and proxy settings from the environment follow the standard library’s behaviour.
123
+
124
+ ### Custom detectors, errors, and logging
125
+
126
+ ```python
127
+ from io import BytesIO
128
+ from vdiskprobe import FormatDetector, build_default_registry, detect
129
+
130
+ class ExampleDetector(FormatDetector):
131
+ format_name = "example"
132
+ description = "Example image"
133
+ priority = 5
134
+
135
+ def detect(self, source):
136
+ if source.read(0, 4) == b"EXMP":
137
+ return self.build_info(source)
138
+ return None
139
+
140
+ registry = build_default_registry()
141
+ registry.register(ExampleDetector())
142
+ info = detect(BytesIO(b"EXMP"), registry=registry)
143
+ assert info.format == "example"
144
+ ```
145
+
146
+ Lower priorities run first; equal priorities retain registration order. The first match wins. Keep raw as the final fallback when creating a registry yourself; if no detector matches, the registry raises `RuntimeError`. Source failures propagate rather than becoming a raw result; refused HTTP ranges follow the bounded detection behaviour described above.
147
+
148
+ `InvalidSourceError` and `RemoteSourceError` inherit from `VDiskProbeError`. Local I/O failures can raise `OSError`. Logging uses a `NullHandler` until configured; call `configure_logging()` to attach a stream handler. Library logging does not configure the application's root logger.
149
+
150
+ ```python
151
+ import logging
152
+ from vdiskprobe import configure_logging
153
+
154
+ configure_logging(logging.DEBUG)
155
+ ```
156
+
157
+ For applications that already configure ancestor handlers, use `configure_logging(logging.DEBUG, propagate=True)`. Enabling propagation removes the package stream handler and null handlers, whilst preserving application handlers. Repeated stream configuration reuses an existing package stream handler; it does not replace its stream. `get_logger(name)` is intended for package modules. Applications using the library should use their own `logging.getLogger(__name__)`.
158
+
159
+ ## Development
160
+
161
+ Run commands from the repository root with a supported Python interpreter. Create and activate an isolated environment, then install the development tools:
162
+
163
+ ```bash
164
+ python -m venv .venv
165
+ source .venv/bin/activate
166
+ python -m pip install -e '.[dev]'
167
+ ```
168
+
169
+ Use the activation command appropriate for your shell. Development tools belong in the `dev` extra in `pyproject.toml`.
170
+
171
+ ### Required checks
172
+
173
+ After every Python change, all four checks must pass: Black, fewer than 1000 lines per Python file, the complete test suite with 100% source statement and branch coverage, and Git whitespace validation. A failed or unavailable check means verification is incomplete.
174
+
175
+ Black reads its settings from `pyproject.toml`. Format the Python files, then check them:
176
+
177
+ ```bash
178
+ python -m black vdiskprobe tests
179
+ python -m black --check vdiskprobe tests
180
+ ```
181
+
182
+ Check every tracked or unignored Python file, including tests and scripts. This prints files with 1000 or more lines and exits with a non-zero status if any exist:
183
+
184
+ ```bash
185
+ python - <<'PYTHON'
186
+ from pathlib import Path
187
+ import subprocess
188
+
189
+ paths = subprocess.check_output([
190
+ "git", "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "*.py"
191
+ ]).decode().split("\0")
192
+ failures = []
193
+ for name in sorted(set(paths)):
194
+ if name and Path(name).is_file():
195
+ count = len(Path(name).read_text(encoding="utf-8").splitlines())
196
+ if count >= 1000:
197
+ failures.append(f"{name}: {count} lines")
198
+ print("\n".join(failures), end="\n" if failures else "")
199
+ raise SystemExit(bool(failures))
200
+ PYTHON
201
+ ```
202
+
203
+ The default pytest command enforces the coverage gate from `pyproject.toml`, measuring only `vdiskprobe`. Tests use synthetic disk headers, injected readers, and a loopback HTTP server. Keep assertions meaningful; coverage alone does not prove correctness.
204
+
205
+ ```bash
206
+ python -m pytest
207
+ git diff --check
208
+ ```
209
+
210
+ For a focused test run without the whole-package coverage gate:
211
+
212
+ ```bash
213
+ python -m pytest --no-cov tests/test_detection.py
214
+ python -m pytest --no-cov tests/test_detection.py -k qcow2
215
+ ```
216
+
217
+ Focused runs do not replace the required full-suite check. When coverage fails, inspect the missing paths in HTML or export XML for tooling:
218
+
219
+ ```bash
220
+ python -m coverage html
221
+ python -m coverage xml
222
+ ```
223
+
224
+ Open `htmlcov/index.html` for the HTML report. Both report commands retain the configured 100% threshold and can exit with a non-zero status while still writing their reports.
225
+
226
+ ### Builds and compatibility
227
+
228
+ Build a wheel and source distribution, then run the explicit packaging checks:
229
+
230
+ ```bash
231
+ python -m build
232
+ python -m pytest -m packaging -o addopts='' tests/test_packaging.py
233
+ ```
234
+
235
+ Packaging tests are excluded from the default suite. They inspect distribution contents and metadata, install both artefacts in separate clean environments, and exercise imports and both CLI entry points outside the checkout. pip must be able to obtain the build dependencies, either from a package index or a local wheel cache.
236
+
237
+ Repeat the full suite and packaging checks on each supported Python minor when changing compatibility or packaging. Support tracks stable Python releases that have not reached end of life; check the Python release lifecycle when updating the supported range. Keep syntax and dependencies compatible with the minimum version listed above.
238
+
239
+ Testing currently covers standard CPython builds. Other Python implementations and free-threaded builds remain unverified.
240
+
241
+ ### Versioning
242
+
243
+ Versions must follow PEP 440, for example `0.1.0`, `1.0.0a1`, or `1.0.0.dev1`. The single source of truth is `[project].version` in `pyproject.toml`. `vdiskprobe/version.py` reads the installed distribution metadata using `importlib.metadata`, and the package re-exports that value as `__version__`. Install the package before importing it; an editable development install is sufficient. After changing the version, rerun `python -m pip install -e '.[dev]'` to refresh editable-install metadata, or rebuild and reinstall the distribution.
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vdiskprobe"
7
+ version = "1.0.0"
8
+ description = "A lightweight Python library and CLI for detecting virtual disk image formats and reading size metadata from local files, custom readers, and HTTP/HTTPS URLs."
9
+ authors = [{email = "SteveJobz@isarvin.com"}]
10
+ readme = "README.md"
11
+ requires-python = ">=3.11"
12
+ license = "MIT"
13
+ license-files = ["LICENCE"]
14
+ dependencies = []
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Programming Language :: Python :: 3.15",
22
+ "Operating System :: OS Independent",
23
+ "Topic :: System :: Filesystems",
24
+ ]
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/ArvinZJC/VDiskProbe"
28
+ Issues = "https://github.com/ArvinZJC/VDiskProbe/issues"
29
+ Changelog = "https://github.com/ArvinZJC/VDiskProbe/blob/main/CHANGELOG.md"
30
+
31
+ [project.scripts]
32
+ vdiskprobe = "vdiskprobe.cli:main"
33
+
34
+ [project.optional-dependencies]
35
+ dev = ["pytest>=8", "pytest-cov>=6", "black>=26.5.0", "build>=1.2", "setuptools>=77"]
36
+
37
+ [tool.setuptools.packages.find]
38
+ include = ["vdiskprobe*"]
39
+ exclude = ["tests*"]
40
+
41
+ [tool.setuptools.data-files]
42
+ "share/doc/vdiskprobe" = ["README.md", "CHANGELOG.md", "LICENCE"]
43
+
44
+ [tool.black]
45
+ line-length = 120
46
+ target-version = ["py311", "py312", "py313", "py314", "py315"]
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+ addopts = '-m "not packaging" --cov=vdiskprobe --cov-branch --cov-report=term-missing'
51
+ markers = ["packaging: build and install distribution artefacts in clean environments"]
52
+
53
+ [tool.coverage.run]
54
+ branch = true
55
+ source = ["vdiskprobe"]
56
+
57
+ [tool.coverage.report]
58
+ fail_under = 100
59
+ show_missing = true
60
+ precision = 2
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+