Skip to content

Commit e0bd351

Browse files
authored
Version 0.5.0 (#65)
* add --uncompress gzip hashing, verify fallback, and CI tests * improve ctrl-c shutdown handling during worker cleanup * make CI run pytest and normalize macOS path assertions * bumps version to 0.5
1 parent 9606fcd commit e0bd351

14 files changed

Lines changed: 515 additions & 106 deletions

File tree

.github/workflows/tests.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
test:
9+
runs-on: ${{ matrix.os }}
10+
strategy:
11+
fail-fast: false
12+
matrix:
13+
os:
14+
- ubuntu-latest
15+
- macos-latest
16+
- windows-latest
17+
python-version:
18+
- "3.8"
19+
- "3.9"
20+
- "3.11"
21+
- "3.12"
22+
23+
steps:
24+
- uses: actions/checkout@v4
25+
26+
- uses: actions/setup-python@v5
27+
with:
28+
python-version: ${{ matrix.python-version }}
29+
30+
- name: Install dependencies
31+
run: python -m pip install -e . pytest
32+
33+
- name: Run tests
34+
run: python -m pytest tests -q

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
## 0.5.0 - 2026-04-04
6+
7+
### Added
8+
9+
- Added `--uncompress` support for hashing the decompressed contents of `.gz` files.
10+
- Added gzip-aware verification fallback so manifest entries can be checked against matching `.gz` files.
11+
- Added basic GitHub Actions test coverage across multiple operating systems and Python versions.
12+
- Added unit coverage for gzip hashing, verification, and shutdown helper behavior.
13+
14+
### Changed
15+
16+
- Manifest output now uses the uncompressed filename when `--uncompress` is enabled.
17+
- `--uncompress` bypasses the hash cache so compressed-byte and decompressed-content hashes are not mixed.
18+
- Improved shutdown handling so `Ctrl+C` exits more cleanly during worker cleanup and progress-thread teardown.
19+
20+
### Fixed
21+
22+
- Fixed SQLite cache merge retries to clean up attached temp databases between attempts.
23+
- Fixed the lock-failure retry test so it still exercises the intended failure path.

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ available) or regenerated hash values if mtimes are missing or different:
7979
$ hashio --verify hash.json
8080
```
8181

82+
To hash the decompressed contents of `.gz` files instead of the archive bytes,
83+
use `--uncompress`. In this mode, manifest entries are written using the
84+
uncompressed filename:
85+
86+
```bash
87+
$ hashio sample.txt.gz -o hash.json --uncompress
88+
$ hashio --verify hash.json --uncompress
89+
```
90+
91+
Note: `--uncompress` currently supports `.gz` files only, and bypasses the hash
92+
cache so compressed-byte hashes are not mixed with decompressed-content hashes.
93+
8294
#### Portability
8395

8496
To make a portable hash file, use `-or` to make the paths relative to the

lib/hashio/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,4 @@
3535

3636
__author__ = "ryan@rsgalloway.com"
3737
__prog__ = "hashio"
38-
__version__ = "0.4.10"
38+
__version__ = "0.5.0"

lib/hashio/cache.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,9 @@ def wrapper(*args, **kwargs):
8888
else:
8989
logger.warning("sqlite3.OperationalError: %s", str(e))
9090
raise
91+
db_path = getattr(args[0], "db_path", "<unknown>")
9192
raise RuntimeError(
92-
f"{fn.__name__} failed after {retries} retries with error: {last_err} ({args[0].db_path})"
93+
f"{fn.__name__} failed after {retries} retries with error: {last_err} ({db_path})"
9394
)
9495

9596
return wrapper
@@ -304,22 +305,30 @@ def merge(self, path: str):
304305
305306
:param path: The path to the other SQLite database file.
306307
"""
307-
self.conn.execute("ATTACH DATABASE ? AS tempdb", (path,))
308-
# do not insert IDs
309-
self.conn.executescript(
308+
attached = False
309+
try:
310+
self.conn.execute("ATTACH DATABASE ? AS tempdb", (path,))
311+
attached = True
312+
# do not insert IDs
313+
self.conn.executescript(
314+
"""
315+
INSERT OR IGNORE INTO files (path, mtime, algo, hash, size, inode)
316+
SELECT path, mtime, algo, hash, size, inode FROM tempdb.files;
317+
318+
INSERT OR IGNORE INTO snapshots (name, created_at, path)
319+
SELECT name, created_at, path FROM tempdb.snapshots;
320+
321+
INSERT OR IGNORE INTO snapshot_files (snapshot_id, file_id)
322+
SELECT snapshot_id, file_id FROM tempdb.snapshot_files;
310323
"""
311-
INSERT OR IGNORE INTO files (path, mtime, algo, hash, size, inode)
312-
SELECT path, mtime, algo, hash, size, inode FROM tempdb.files;
313-
314-
INSERT OR IGNORE INTO snapshots (name, created_at, path)
315-
SELECT name, created_at, path FROM tempdb.snapshots;
316-
317-
INSERT OR IGNORE INTO snapshot_files (snapshot_id, file_id)
318-
SELECT snapshot_id, file_id FROM tempdb.snapshot_files;
319-
"""
320-
)
321-
self.conn.commit()
322-
self.conn.execute("DETACH DATABASE tempdb")
324+
)
325+
self.conn.commit()
326+
finally:
327+
if attached:
328+
try:
329+
self.conn.execute("DETACH DATABASE tempdb")
330+
except sqlite3.Error:
331+
pass
323332

324333
def query(
325334
self, pattern: str, algo: Optional[str] = None, since: Optional[str] = None

lib/hashio/cli.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,24 @@
4848
from hashio.worker import HashWorker
4949

5050

51+
def safe_join_thread(thread: threading.Thread, timeout: float = None):
52+
"""Join a thread while suppressing KeyboardInterrupt during shutdown."""
53+
try:
54+
thread.join(timeout=timeout)
55+
except KeyboardInterrupt:
56+
return False
57+
return not thread.is_alive()
58+
59+
60+
def stop_workers(workers):
61+
"""Stop workers while suppressing repeated interrupts during cleanup."""
62+
for worker in workers:
63+
try:
64+
worker.stop()
65+
except KeyboardInterrupt:
66+
continue
67+
68+
5169
def format_result(row):
5270
"""Format a row from the cache query into a human-readable string."""
5371
# unpack row data
@@ -162,6 +180,11 @@ def parse_args():
162180
nargs="*",
163181
help="verify checksums from a previously created hash file",
164182
)
183+
parser.add_argument(
184+
"--uncompress",
185+
action="store_true",
186+
help="hash and verify the decompressed contents of .gz files",
187+
)
165188

166189
# mutually exclusive group for cache operations
167190
cache_group = parser.add_argument_group("cache")
@@ -351,11 +374,13 @@ def is_under_root(path, root):
351374
print(f"file not found: {config.CACHE_FILENAME}")
352375
return 0
353376
for algo, value, miss in verify_checksums(
354-
config.CACHE_FILENAME, start=args.start
377+
config.CACHE_FILENAME, start=args.start, uncompress=args.uncompress
355378
):
356379
print("{0} {1}".format(algo, miss))
357380
elif len(args.verify) == 1:
358-
for algo, value, miss in verify_checksums(args.verify[0], start=args.start):
381+
for algo, value, miss in verify_checksums(
382+
args.verify[0], start=args.start, uncompress=args.uncompress
383+
):
359384
print("{0} {1}".format(algo, miss))
360385
elif len(args.verify) == 2:
361386
source = args.verify[0]
@@ -392,6 +417,7 @@ def is_under_root(path, root):
392417
snapshot=args_dict["snapshot"],
393418
force=args_dict["force"],
394419
verbose=verbose,
420+
uncompress=args_dict["uncompress"],
395421
)
396422
workers.append(worker)
397423

@@ -410,18 +436,18 @@ def is_under_root(path, root):
410436
while any(t.is_alive() for t in worker_threads):
411437
# wait for all workers to finish
412438
for t in worker_threads:
413-
t.join(timeout=0.2)
439+
safe_join_thread(t, timeout=0.2)
414440

415441
# now wait for progress threads to exit
416442
for t in progress_threads:
417-
t.join()
443+
safe_join_thread(t, timeout=0.2)
418444

419445
except KeyboardInterrupt:
420-
for worker in workers:
421-
worker.stop()
446+
stop_workers(workers)
422447
for t in progress_threads:
423-
t.join(timeout=0.2)
448+
safe_join_thread(t, timeout=0.2)
424449
print("stopping...")
450+
return 130
425451

426452
finally:
427453
if len(paths) > 1 and not (args.verbose or args.summarize):

lib/hashio/encoder.py

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@
3737
import os
3838
import xxhash
3939
import zlib
40-
from typing import List, Tuple
40+
from typing import List, Tuple, Union
4141

4242
from hashio import config
4343
from hashio.exporter import CacheExporter
4444
from hashio.logger import logger
45-
from hashio.utils import read_file, walk
45+
from hashio.utils import is_gzip_path, read_file, read_file_uncompressed, walk
4646

4747

4848
def bytes_to_long(data: bytes):
@@ -321,21 +321,35 @@ def checksum_data(data: bytes, encoder: Encoder, buffer_size: int = config.BUF_S
321321
return value
322322

323323

324-
def checksum_file(path: str, encoder: Encoder, buffer_size: int = config.BUF_SIZE):
324+
def checksum_file(
325+
path: str,
326+
encoder: Encoder,
327+
buffer_size: int = config.BUF_SIZE,
328+
uncompress: bool = False,
329+
with_size: bool = False,
330+
) -> Union[str, Tuple[str, int]]:
325331
"""Creates a checksum for a given filepath and encoder. Note: resets
326332
encoder, existing data will be lost.
327333
328334
>>> checksum_file("example.txt", MD5Encoder())
329335
330336
:param path: the path to the filepath being hashed
331337
:param encoder: instance of Encoder subclass
332-
:return: hexdigest of the checksum
338+
:param buffer_size: size of each read chunk in bytes
339+
:param uncompress: if True, hash decompressed contents for supported files
340+
:param with_size: if True, also return the total bytes hashed
341+
:return: checksum hex digest, or ``(checksum, size)`` when ``with_size`` is True
333342
"""
334343
encoder.reset()
335-
for data in read_file(path, buffer_size=buffer_size):
344+
size = 0
345+
reader = read_file_uncompressed if uncompress and is_gzip_path(path) else read_file
346+
for data in reader(path, buffer_size=buffer_size):
347+
size += len(data)
336348
encoder.update(data)
337349
value = encoder.hexdigest()
338350
encoder.reset()
351+
if with_size:
352+
return value, size
339353
return value
340354

341355

@@ -372,7 +386,11 @@ def checksum_text(data: str, encoder: Encoder):
372386

373387

374388
def checksum_path(
375-
path: str, encoder: Encoder, filetype: str = "a", use_cache: bool = True
389+
path: str,
390+
encoder: Encoder,
391+
filetype: str = "a",
392+
use_cache: bool = True,
393+
uncompress: bool = False,
376394
):
377395
"""Returns a checksum of for a given path, encoder and filetype.
378396
@@ -384,12 +402,12 @@ def checksum_path(
384402
:param use_cache: cache results to filesystem
385403
:return: hexdigest of the checksum
386404
"""
387-
if use_cache:
405+
if use_cache and not uncompress:
388406
cached_value = CacheExporter.find(path, encoder.name)
389407
if cached_value:
390408
return cached_value
391409
if os.path.isfile(path) and filetype in ("a", "f"):
392-
return checksum_file(path, encoder)
410+
return checksum_file(path, encoder, uncompress=uncompress)
393411
elif os.path.isdir(path) and filetype in ("a", "d"):
394412
return checksum_folder(path, encoder)
395413

@@ -400,6 +418,7 @@ def checksum_gen(
400418
filetype: str = "f",
401419
recursive: bool = True,
402420
use_cache: bool = True,
421+
uncompress: bool = False,
403422
):
404423
"""Checksum generator that yields tuple of (filepath, value).
405424
@@ -415,12 +434,12 @@ def checksum_gen(
415434
"""
416435
if recursive:
417436
for subpath in walk(path, filetype):
418-
value = checksum_path(subpath, encoder, filetype, use_cache)
437+
value = checksum_path(subpath, encoder, filetype, use_cache, uncompress)
419438
if value:
420439
yield (subpath, value)
421440

422441
else:
423-
value = checksum_path(path, encoder, filetype, use_cache)
442+
value = checksum_path(path, encoder, filetype, use_cache, uncompress)
424443
if value:
425444
yield (path, value)
426445

@@ -629,7 +648,7 @@ def dedupe_caches(target: str, source: str, algo: str = config.DEFAULT_ALGO):
629648
return [(t, s) for t, s in dedupe_cache_gen(target, source, algo=algo)]
630649

631650

632-
def verify_checksums(path: str, start: str = None):
651+
def verify_checksums(path: str, start: str = None, uncompress: bool = False):
633652
"""Generator that yields a data tuple for hash misses in a previously
634653
generated output file. Compares mtimes in the output file with the
635654
filesystem.
@@ -653,29 +672,36 @@ def verify_checksums(path: str, start: str = None):
653672

654673
for filename, metadata in data.items():
655674
filepath = os.path.join(root, filename)
675+
hash_path = filepath
676+
677+
if uncompress and not os.path.exists(hash_path):
678+
gzip_path = f"{filepath}.gz"
679+
if os.path.exists(gzip_path):
680+
hash_path = gzip_path
656681

657682
# iterate over all the hash algos...
658683
for algo in ENCODER_MAP.keys():
659684
if algo not in metadata.keys():
660685
continue
661686

662687
# check if file exists and compare mtimes
663-
if not os.path.exists(filepath):
688+
if not os.path.exists(hash_path):
664689
logger.warning("missing: %s", filepath)
665690
continue
666691
# if mtimes match, skip
667-
elif metadata.get("mtime") == os.stat(filepath).st_mtime:
692+
elif metadata.get("mtime") == os.stat(hash_path).st_mtime:
668693
continue
669694

670695
# if mtimes don't match, re-hash the file
671-
logger.debug("mtime miss on %s", filepath)
696+
logger.debug("mtime miss on %s", hash_path)
672697
old_value = metadata.get(algo)
673698
encoder = ENCODER_MAP.get(algo)()
674-
new_value = checksum_path(filepath, encoder)
699+
do_uncompress = uncompress and is_gzip_path(hash_path)
700+
new_value = checksum_path(hash_path, encoder, uncompress=do_uncompress)
675701

676702
# hash values don't match, file must have changed
677703
if (new_value and old_value) and (new_value != old_value):
678-
logger.debug("hash miss on %s %s", algo, filepath)
704+
logger.debug("hash miss on %s %s", algo, hash_path)
679705
yield (algo, new_value, filepath)
680706

681707

0 commit comments

Comments
 (0)