hashio 0.5.1__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.
- hashio/__init__.py +38 -0
- hashio/cache.py +657 -0
- hashio/cli.py +518 -0
- hashio/config.py +152 -0
- hashio/encoder.py +742 -0
- hashio/exporter.py +397 -0
- hashio/logger.py +50 -0
- hashio/utils.py +272 -0
- hashio/worker.py +497 -0
- hashio-0.5.1.dist-info/LICENSE +12 -0
- hashio-0.5.1.dist-info/METADATA +322 -0
- hashio-0.5.1.dist-info/RECORD +15 -0
- hashio-0.5.1.dist-info/WHEEL +5 -0
- hashio-0.5.1.dist-info/entry_points.txt +2 -0
- hashio-0.5.1.dist-info/top_level.txt +1 -0
hashio/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
|
|
4
|
+
#
|
|
5
|
+
# Redistribution and use in source and binary forms, with or without
|
|
6
|
+
# modification, are permitted provided that the following conditions are met:
|
|
7
|
+
#
|
|
8
|
+
# - Redistributions of source code must retain the above copyright notice,
|
|
9
|
+
# this list of conditions and the following disclaimer.
|
|
10
|
+
#
|
|
11
|
+
# - Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
# and/or other materials provided with the distribution.
|
|
14
|
+
#
|
|
15
|
+
# - Neither the name of the software nor the names of its contributors
|
|
16
|
+
# may be used to endorse or promote products derived from this software
|
|
17
|
+
# without specific prior written permission.
|
|
18
|
+
#
|
|
19
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
22
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
23
|
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
24
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
25
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
26
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
27
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
28
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
29
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
|
30
|
+
#
|
|
31
|
+
|
|
32
|
+
__doc__ = """
|
|
33
|
+
Custom file and directory checksum tool.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__author__ = "ryan@rsgalloway.com"
|
|
37
|
+
__prog__ = "hashio"
|
|
38
|
+
__version__ = "0.5.1"
|
hashio/cache.py
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
|
|
4
|
+
#
|
|
5
|
+
# Redistribution and use in source and binary forms, with or without
|
|
6
|
+
# modification, are permitted provided that the following conditions are met:
|
|
7
|
+
#
|
|
8
|
+
# - Redistributions of source code must retain the above copyright notice,
|
|
9
|
+
# this list of conditions and the following disclaimer.
|
|
10
|
+
#
|
|
11
|
+
# - Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
# and/or other materials provided with the distribution.
|
|
14
|
+
#
|
|
15
|
+
# - Neither the name of the software nor the names of its contributors
|
|
16
|
+
# may be used to endorse or promote products derived from this software
|
|
17
|
+
# without specific prior written permission.
|
|
18
|
+
#
|
|
19
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
22
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
23
|
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
24
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
25
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
26
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
27
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
28
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
29
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
|
30
|
+
#
|
|
31
|
+
|
|
32
|
+
__doc__ = """
|
|
33
|
+
Contains global cache classes and functions.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import fnmatch
|
|
37
|
+
import functools
|
|
38
|
+
import os
|
|
39
|
+
import random
|
|
40
|
+
import time
|
|
41
|
+
|
|
42
|
+
from datetime import datetime
|
|
43
|
+
from collections import OrderedDict
|
|
44
|
+
from typing import Optional
|
|
45
|
+
|
|
46
|
+
from hashio.config import DEFAULT_DB_PATH
|
|
47
|
+
from hashio.logger import logger
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
import sqlite3
|
|
51
|
+
except ModuleNotFoundError:
|
|
52
|
+
sqlite3 = None
|
|
53
|
+
|
|
54
|
+
# sqlite3 OperationalError messages that indicate a locked database
|
|
55
|
+
LOCK_MESSAGES = [
|
|
56
|
+
"database is locked",
|
|
57
|
+
"database schema is locked",
|
|
58
|
+
"database table is locked",
|
|
59
|
+
"database is busy",
|
|
60
|
+
"already in use",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SQLiteUnavailableError(RuntimeError):
|
|
65
|
+
"""Raised when cache functionality is requested without sqlite3 support."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def require_sqlite_support():
|
|
69
|
+
"""Raise a clear error if sqlite3 is unavailable."""
|
|
70
|
+
if sqlite3 is None:
|
|
71
|
+
raise SQLiteUnavailableError(
|
|
72
|
+
"sqlite3 support is required for cache and snapshot features."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def require_sqlite(fn):
|
|
77
|
+
"""Ensure sqlite3 support exists before using cache functionality."""
|
|
78
|
+
|
|
79
|
+
@functools.wraps(fn)
|
|
80
|
+
def wrapper(*args, **kwargs):
|
|
81
|
+
require_sqlite_support()
|
|
82
|
+
return fn(*args, **kwargs)
|
|
83
|
+
|
|
84
|
+
return wrapper
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def with_retry(retries: int = 5, delay: float = 0.2, backoff: float = 1.25):
|
|
88
|
+
"""Retry a function on SQLite 'database is locked' errors using exponential backoff."""
|
|
89
|
+
|
|
90
|
+
def decorator(fn):
|
|
91
|
+
@functools.wraps(fn)
|
|
92
|
+
def wrapper(*args, **kwargs):
|
|
93
|
+
require_sqlite_support()
|
|
94
|
+
_delay = delay
|
|
95
|
+
last_err = None
|
|
96
|
+
for i in range(retries):
|
|
97
|
+
try:
|
|
98
|
+
return fn(*args, **kwargs)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
if not isinstance(e, sqlite3.OperationalError):
|
|
101
|
+
raise
|
|
102
|
+
logger.debug(
|
|
103
|
+
f"{fn.__name__} attempt {i + 1} with delay {_delay:.2f}s"
|
|
104
|
+
)
|
|
105
|
+
err_msg = str(e).lower()
|
|
106
|
+
if any(msg in err_msg for msg in LOCK_MESSAGES):
|
|
107
|
+
last_err = e
|
|
108
|
+
# rollback if in transaction
|
|
109
|
+
conn = getattr(args[0], "conn", None)
|
|
110
|
+
if conn and getattr(conn, "in_transaction", False):
|
|
111
|
+
try:
|
|
112
|
+
conn.rollback()
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
# jitter to reduce contention
|
|
116
|
+
time.sleep(_delay + random.uniform(0, 0.1))
|
|
117
|
+
_delay *= backoff
|
|
118
|
+
else:
|
|
119
|
+
logger.warning("sqlite3.OperationalError: %s", str(e))
|
|
120
|
+
raise
|
|
121
|
+
db_path = getattr(args[0], "db_path", "<unknown>")
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
f"{fn.__name__} failed after {retries} retries with error: {last_err} ({db_path})"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return wrapper
|
|
127
|
+
|
|
128
|
+
return decorator
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class LRU:
|
|
132
|
+
"""A simple LRU cache implementation using an OrderedDict.
|
|
133
|
+
|
|
134
|
+
Usage:
|
|
135
|
+
|
|
136
|
+
>>> lru = LRU(maxsize=100)
|
|
137
|
+
>>> lru.put('key1', 'value1')
|
|
138
|
+
>>> value = lru.get('key1') # returns 'value1'
|
|
139
|
+
>>> lru.put('key2', 'value2') # adds another item
|
|
140
|
+
>>> # if maxsize is exceeded, the oldest item will be removed.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, maxsize: int = 100_000):
|
|
144
|
+
"""Initialize the LRU cache.
|
|
145
|
+
|
|
146
|
+
:param maxsize: Maximum number of items to store in the cache.
|
|
147
|
+
"""
|
|
148
|
+
self.maxsize = maxsize
|
|
149
|
+
self.cache = OrderedDict()
|
|
150
|
+
|
|
151
|
+
def get(self, key: str):
|
|
152
|
+
"""Retrieve a value from the cache by key."""
|
|
153
|
+
if key in self.cache:
|
|
154
|
+
self.cache.move_to_end(key)
|
|
155
|
+
return self.cache[key]
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def put(self, key: str, value: bool = True):
|
|
159
|
+
"""Store a key-value pair in the cache."""
|
|
160
|
+
self.cache[key] = value
|
|
161
|
+
self.cache.move_to_end(key)
|
|
162
|
+
if len(self.cache) > self.maxsize:
|
|
163
|
+
self.cache.popitem(last=False)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Cache:
|
|
167
|
+
"""A class to manage a SQLite database cache for file hashes."""
|
|
168
|
+
|
|
169
|
+
@require_sqlite
|
|
170
|
+
def __init__(self, db_path: str = DEFAULT_DB_PATH):
|
|
171
|
+
"""Initialize the cache with a database path.
|
|
172
|
+
|
|
173
|
+
:param db_path: The path to the SQLite database file.
|
|
174
|
+
"""
|
|
175
|
+
self.db_path = os.path.abspath(db_path)
|
|
176
|
+
self.conn = None
|
|
177
|
+
self.lru = LRU()
|
|
178
|
+
self._ensure_db()
|
|
179
|
+
|
|
180
|
+
@with_retry()
|
|
181
|
+
def _ensure_db(self):
|
|
182
|
+
"""Ensure the database and its tables exist, creating them if necessary."""
|
|
183
|
+
if not os.path.exists(os.path.dirname(self.db_path)):
|
|
184
|
+
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
|
185
|
+
self.conn = sqlite3.connect(self.db_path)
|
|
186
|
+
# create files table if it does not exist
|
|
187
|
+
self.conn.execute(
|
|
188
|
+
"""
|
|
189
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
190
|
+
id INTEGER PRIMARY KEY,
|
|
191
|
+
path TEXT NOT NULL,
|
|
192
|
+
mtime REAL NOT NULL,
|
|
193
|
+
algo TEXT NOT NULL,
|
|
194
|
+
hash TEXT,
|
|
195
|
+
size INTEGER,
|
|
196
|
+
inode TEXT,
|
|
197
|
+
updated_at REAL DEFAULT (strftime('%s','now')),
|
|
198
|
+
UNIQUE(path, mtime, algo)
|
|
199
|
+
)
|
|
200
|
+
"""
|
|
201
|
+
)
|
|
202
|
+
# create snapshots and snapshot_files tables if they do not exist
|
|
203
|
+
self.conn.execute(
|
|
204
|
+
"""
|
|
205
|
+
CREATE TABLE IF NOT EXISTS snapshots (
|
|
206
|
+
id INTEGER PRIMARY KEY,
|
|
207
|
+
name TEXT UNIQUE,
|
|
208
|
+
path TEXT,
|
|
209
|
+
created_at REAL DEFAULT (strftime('%s','now'))
|
|
210
|
+
)
|
|
211
|
+
"""
|
|
212
|
+
)
|
|
213
|
+
self.conn.execute(
|
|
214
|
+
"""
|
|
215
|
+
CREATE TABLE IF NOT EXISTS snapshot_files (
|
|
216
|
+
snapshot_id INTEGER NOT NULL,
|
|
217
|
+
file_id INTEGER NOT NULL,
|
|
218
|
+
PRIMARY KEY (snapshot_id, file_id),
|
|
219
|
+
FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE,
|
|
220
|
+
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
|
221
|
+
);
|
|
222
|
+
"""
|
|
223
|
+
)
|
|
224
|
+
# enable foreign keys and set performance optimizations
|
|
225
|
+
self.conn.execute("PRAGMA foreign_keys = ON")
|
|
226
|
+
# use Write-Ahead Logging for better concurrency
|
|
227
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
228
|
+
# set synchronous mode to NORMAL for better performance
|
|
229
|
+
self.conn.execute("PRAGMA synchronous=NORMAL")
|
|
230
|
+
# set busy timeout to 0ms to rely on retry decorator for lock handling
|
|
231
|
+
self.conn.execute("PRAGMA busy_timeout=0")
|
|
232
|
+
# create indexes for faster lookups
|
|
233
|
+
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
|
|
234
|
+
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_files_algo ON files(algo)")
|
|
235
|
+
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_files_hash ON files(hash)")
|
|
236
|
+
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_files_inode ON files(inode)")
|
|
237
|
+
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_files_mtime ON files(mtime)")
|
|
238
|
+
self.conn.execute(
|
|
239
|
+
"CREATE INDEX IF NOT EXISTS idx_files_hash_inode ON files(hash, inode)"
|
|
240
|
+
)
|
|
241
|
+
self.conn.execute(
|
|
242
|
+
"CREATE INDEX IF NOT EXISTS idx_files_path_mtime_algo ON files(path, mtime, algo)"
|
|
243
|
+
)
|
|
244
|
+
self.conn.commit()
|
|
245
|
+
|
|
246
|
+
def flush(self):
|
|
247
|
+
"""Flush any pending changes to the database."""
|
|
248
|
+
try:
|
|
249
|
+
self.conn.commit()
|
|
250
|
+
except sqlite3.Error as e:
|
|
251
|
+
logger.warning(f"SQLite flush error: {e}")
|
|
252
|
+
|
|
253
|
+
def get(self, path: str, mtime: float, algo: str):
|
|
254
|
+
"""Retrieve the hash for a given path, mtime, and algorithm.
|
|
255
|
+
|
|
256
|
+
:param path: The file path to query.
|
|
257
|
+
:param mtime: The last modified time of the file.
|
|
258
|
+
:param algo: The hashing algorithm used.
|
|
259
|
+
:return: The hash value if found, otherwise None.
|
|
260
|
+
"""
|
|
261
|
+
cur = self.conn.cursor()
|
|
262
|
+
cur.execute(
|
|
263
|
+
"""
|
|
264
|
+
SELECT hash FROM files
|
|
265
|
+
WHERE path = ? AND mtime = ? AND algo = ?
|
|
266
|
+
""",
|
|
267
|
+
(path, mtime, algo),
|
|
268
|
+
)
|
|
269
|
+
row = cur.fetchone()
|
|
270
|
+
return row[0] if row else None
|
|
271
|
+
|
|
272
|
+
def has(self, path, mtime, algo, hashval):
|
|
273
|
+
"""Check if a hash entry exists in the cache.
|
|
274
|
+
|
|
275
|
+
:param path: The file path to check.
|
|
276
|
+
:param mtime: The last modified time of the file.
|
|
277
|
+
:param algo: The hashing algorithm used.
|
|
278
|
+
:param hashval: The hash value to check.
|
|
279
|
+
:return: True if the entry exists, otherwise False.
|
|
280
|
+
"""
|
|
281
|
+
key = (path, mtime, algo, hashval)
|
|
282
|
+
if self.lru.get(key):
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
cur = self.conn.cursor()
|
|
286
|
+
cur.execute(
|
|
287
|
+
"""
|
|
288
|
+
SELECT 1 FROM files
|
|
289
|
+
WHERE path=? AND mtime=? AND algo=? AND hash=?
|
|
290
|
+
""",
|
|
291
|
+
key,
|
|
292
|
+
)
|
|
293
|
+
exists = cur.fetchone() is not None
|
|
294
|
+
if exists:
|
|
295
|
+
self.lru.put(key)
|
|
296
|
+
return exists
|
|
297
|
+
|
|
298
|
+
@with_retry()
|
|
299
|
+
def put(
|
|
300
|
+
self, path: str, mtime: float, algo: str, hashval: str, size: int, inode: int
|
|
301
|
+
):
|
|
302
|
+
"""Store a hash entry in the cache. If the entry already exists, it will
|
|
303
|
+
return the existing ID instead of inserting a new row.
|
|
304
|
+
|
|
305
|
+
:param path: The file path to store.
|
|
306
|
+
:param mtime: The last modified time of the file.
|
|
307
|
+
:param algo: The hashing algorithm used.
|
|
308
|
+
:param hashval: The hash value to store.
|
|
309
|
+
:param size: The size of the file.
|
|
310
|
+
:param inode: The inode number of the file.
|
|
311
|
+
"""
|
|
312
|
+
cur = self.conn.cursor()
|
|
313
|
+
cur.execute(
|
|
314
|
+
"""
|
|
315
|
+
INSERT OR IGNORE INTO files (path, mtime, algo, hash, size, inode, updated_at)
|
|
316
|
+
VALUES (?, ?, ?, ?, ?, ?, strftime('%s','now'))
|
|
317
|
+
""",
|
|
318
|
+
(path, mtime, algo, hashval, size, str(inode)),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# fetch the ID of the existing or newly inserted row
|
|
322
|
+
cur.execute(
|
|
323
|
+
"""
|
|
324
|
+
SELECT id FROM files WHERE path=? AND mtime=? AND algo=?
|
|
325
|
+
""",
|
|
326
|
+
(path, mtime, algo),
|
|
327
|
+
)
|
|
328
|
+
row = cur.fetchone()
|
|
329
|
+
return row[0] if row else None
|
|
330
|
+
|
|
331
|
+
@with_retry(retries=10)
|
|
332
|
+
def merge(self, path: str):
|
|
333
|
+
"""Merge another database into this cache. This will insert all unique
|
|
334
|
+
entries from the other database into the current cache. This will lock
|
|
335
|
+
the database for the duration of the merge.
|
|
336
|
+
|
|
337
|
+
:param path: The path to the other SQLite database file.
|
|
338
|
+
"""
|
|
339
|
+
attached = False
|
|
340
|
+
try:
|
|
341
|
+
self.conn.execute("ATTACH DATABASE ? AS tempdb", (path,))
|
|
342
|
+
attached = True
|
|
343
|
+
# do not insert IDs
|
|
344
|
+
self.conn.executescript(
|
|
345
|
+
"""
|
|
346
|
+
INSERT OR IGNORE INTO files (path, mtime, algo, hash, size, inode)
|
|
347
|
+
SELECT path, mtime, algo, hash, size, inode FROM tempdb.files;
|
|
348
|
+
|
|
349
|
+
INSERT OR IGNORE INTO snapshots (name, created_at, path)
|
|
350
|
+
SELECT name, created_at, path FROM tempdb.snapshots;
|
|
351
|
+
|
|
352
|
+
INSERT OR IGNORE INTO snapshot_files (snapshot_id, file_id)
|
|
353
|
+
SELECT snapshot_id, file_id FROM tempdb.snapshot_files;
|
|
354
|
+
"""
|
|
355
|
+
)
|
|
356
|
+
self.conn.commit()
|
|
357
|
+
finally:
|
|
358
|
+
if attached:
|
|
359
|
+
try:
|
|
360
|
+
self.conn.execute("DETACH DATABASE tempdb")
|
|
361
|
+
except sqlite3.Error:
|
|
362
|
+
pass
|
|
363
|
+
|
|
364
|
+
def query(
|
|
365
|
+
self, pattern: str, algo: Optional[str] = None, since: Optional[str] = None
|
|
366
|
+
):
|
|
367
|
+
"""Query the cache for files matching a pattern.
|
|
368
|
+
|
|
369
|
+
:param pattern: The pattern to match against file paths.
|
|
370
|
+
:param algo: Optional hashing algorithm to filter results.
|
|
371
|
+
:param since: Optional timestamp to filter results by last updated time.
|
|
372
|
+
:return: A list of tuples containing matching files.
|
|
373
|
+
"""
|
|
374
|
+
sql = "SELECT * FROM files"
|
|
375
|
+
params = []
|
|
376
|
+
filters = []
|
|
377
|
+
|
|
378
|
+
# algo filter
|
|
379
|
+
if algo:
|
|
380
|
+
filters.append("algo = ?")
|
|
381
|
+
params.append(algo)
|
|
382
|
+
|
|
383
|
+
# time filter
|
|
384
|
+
if since:
|
|
385
|
+
try:
|
|
386
|
+
ts = datetime.fromisoformat(since).timestamp()
|
|
387
|
+
filters.append("updated_at >= ?")
|
|
388
|
+
params.append(ts)
|
|
389
|
+
except ValueError:
|
|
390
|
+
raise ValueError(f"Invalid --since datetime: {since}")
|
|
391
|
+
|
|
392
|
+
# base wildcard logic
|
|
393
|
+
if "*" in pattern or "?" in pattern or "[" in pattern:
|
|
394
|
+
self.conn.row_factory = sqlite3.Row
|
|
395
|
+
cur = self.conn.cursor()
|
|
396
|
+
cur.execute(sql)
|
|
397
|
+
rows = cur.fetchall()
|
|
398
|
+
return [
|
|
399
|
+
row
|
|
400
|
+
for row in rows
|
|
401
|
+
if fnmatch.fnmatch(row["path"], pattern)
|
|
402
|
+
and all(row[k] == v for k, v in zip(["algo"], [algo]) if algo)
|
|
403
|
+
and (not since or row["updated_at"] >= ts)
|
|
404
|
+
]
|
|
405
|
+
else:
|
|
406
|
+
filters.append("path = ?")
|
|
407
|
+
params.append(pattern)
|
|
408
|
+
if filters:
|
|
409
|
+
sql += " WHERE " + " AND ".join(filters)
|
|
410
|
+
cur = self.conn.cursor()
|
|
411
|
+
cur.execute(sql, tuple(params))
|
|
412
|
+
return cur.fetchall()
|
|
413
|
+
|
|
414
|
+
def get_file_id(self, path: str, mtime: float, algo: str):
|
|
415
|
+
"""Return the ID of a file entry matching path, mtime, algo.
|
|
416
|
+
|
|
417
|
+
:param path: The file path to query.
|
|
418
|
+
:param mtime: The last modified time of the file.
|
|
419
|
+
:param algo: The hashing algorithm used.
|
|
420
|
+
:return: The ID of the file entry if found, otherwise None.
|
|
421
|
+
"""
|
|
422
|
+
cur = self.conn.cursor()
|
|
423
|
+
cur.execute(
|
|
424
|
+
"SELECT id FROM files WHERE path=? AND mtime=? AND algo=?",
|
|
425
|
+
(path, mtime, algo),
|
|
426
|
+
)
|
|
427
|
+
row = cur.fetchone()
|
|
428
|
+
return row[0] if row else None
|
|
429
|
+
|
|
430
|
+
@with_retry()
|
|
431
|
+
def put_file_and_get_id(
|
|
432
|
+
self, path: str, mtime: float, algo: str, hashval: str, size: int, inode: int
|
|
433
|
+
):
|
|
434
|
+
"""Insert or reuse a file entry, and return its ID."""
|
|
435
|
+
cur = self.conn.cursor()
|
|
436
|
+
cur.execute(
|
|
437
|
+
"""
|
|
438
|
+
INSERT OR IGNORE INTO files (path, mtime, algo, hash, size, inode, updated_at)
|
|
439
|
+
VALUES (?, ?, ?, ?, ?, ?, strftime('%s','now'))
|
|
440
|
+
""",
|
|
441
|
+
(path, mtime, algo, hashval, size, str(inode)),
|
|
442
|
+
)
|
|
443
|
+
return self.get_file_id(path, mtime, algo)
|
|
444
|
+
|
|
445
|
+
@with_retry()
|
|
446
|
+
def get_or_create_snapshot(self, name: str, path: str = None):
|
|
447
|
+
"""Get the ID of an existing snapshot by name, or create it if it
|
|
448
|
+
doesn't exist.
|
|
449
|
+
|
|
450
|
+
:param name: The name of the snapshot to retrieve or create.
|
|
451
|
+
:param path: Optional root path for the snapshot.
|
|
452
|
+
:return: The ID of the snapshot.
|
|
453
|
+
"""
|
|
454
|
+
cur = self.conn.cursor()
|
|
455
|
+
cur.execute("SELECT id FROM snapshots WHERE name = ?", (name,))
|
|
456
|
+
row = cur.fetchone()
|
|
457
|
+
if row:
|
|
458
|
+
return row[0]
|
|
459
|
+
cur.execute("INSERT INTO snapshots (name, path) VALUES (?, ?)", (name, path))
|
|
460
|
+
self.conn.commit()
|
|
461
|
+
return cur.lastrowid
|
|
462
|
+
|
|
463
|
+
@with_retry()
|
|
464
|
+
def replace_snapshot(self, name: str, path: str = None):
|
|
465
|
+
"""Deletes any existing snapshot with the same name and creates a fresh one.
|
|
466
|
+
|
|
467
|
+
:param name: The name of the snapshot to replace.
|
|
468
|
+
:param path: Optional root path for the snapshot.
|
|
469
|
+
:return: The ID of the newly created snapshot.
|
|
470
|
+
"""
|
|
471
|
+
self.delete_snapshot(name)
|
|
472
|
+
return self.get_or_create_snapshot(name, path)
|
|
473
|
+
|
|
474
|
+
def get_snapshot_id(self, name: str):
|
|
475
|
+
"""Retrieve the ID of a snapshot by name.
|
|
476
|
+
|
|
477
|
+
:param name: The name of the snapshot to retrieve.
|
|
478
|
+
:return: The ID of the snapshot, or None if it does not exist.
|
|
479
|
+
"""
|
|
480
|
+
cur = self.conn.cursor()
|
|
481
|
+
cur.execute("SELECT id FROM snapshots WHERE name = ?", (name,))
|
|
482
|
+
row = cur.fetchone()
|
|
483
|
+
return row[0] if row else None
|
|
484
|
+
|
|
485
|
+
def get_all_file_ids(self):
|
|
486
|
+
"""Retrieve all file IDs in the database.
|
|
487
|
+
|
|
488
|
+
:return: A list of file IDs.
|
|
489
|
+
"""
|
|
490
|
+
cur = self.conn.cursor()
|
|
491
|
+
cur.execute("SELECT id FROM files")
|
|
492
|
+
return [row[0] for row in cur.fetchall()]
|
|
493
|
+
|
|
494
|
+
@with_retry()
|
|
495
|
+
def delete_snapshot(self, name: str):
|
|
496
|
+
"""Delete a snapshot by name.
|
|
497
|
+
|
|
498
|
+
:param name: The name of the snapshot to delete.
|
|
499
|
+
:raises ValueError: If the snapshot does not exist.
|
|
500
|
+
"""
|
|
501
|
+
self.conn.execute("DELETE FROM snapshots WHERE name = ?", (name,))
|
|
502
|
+
self.conn.commit()
|
|
503
|
+
|
|
504
|
+
@with_retry()
|
|
505
|
+
def add_to_snapshot(self, snapshot_id: int, file_id: int):
|
|
506
|
+
"""Link an entry to a snapshot.
|
|
507
|
+
|
|
508
|
+
:param snapshot_id: The ID of the snapshot to link to.
|
|
509
|
+
:param file_id: The file id to link.
|
|
510
|
+
"""
|
|
511
|
+
self.conn.execute(
|
|
512
|
+
"""
|
|
513
|
+
INSERT OR IGNORE INTO snapshot_files (snapshot_id, file_id)
|
|
514
|
+
VALUES (?, ?)
|
|
515
|
+
""",
|
|
516
|
+
(snapshot_id, file_id),
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
@with_retry()
|
|
520
|
+
def batch_add_snapshot_links(self, snapshot_id: int, file_ids: list):
|
|
521
|
+
"""Batch-insert links between a snapshot and file IDs.
|
|
522
|
+
|
|
523
|
+
:param snapshot_id: The ID of the snapshot to link to.
|
|
524
|
+
:param file_ids: A list of file IDs to link to the snapshot.
|
|
525
|
+
:raises ValueError: If file_ids is empty.
|
|
526
|
+
"""
|
|
527
|
+
if not file_ids:
|
|
528
|
+
return
|
|
529
|
+
records = [(snapshot_id, fid) for fid in file_ids]
|
|
530
|
+
self.conn.executemany(
|
|
531
|
+
"""
|
|
532
|
+
INSERT OR IGNORE INTO snapshot_files (snapshot_id, file_id)
|
|
533
|
+
VALUES (?, ?)
|
|
534
|
+
""",
|
|
535
|
+
records,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def list_snapshots(self):
|
|
539
|
+
"""Return all snapshots.
|
|
540
|
+
|
|
541
|
+
:return: A list of dictionaries containing snapshot IDs, names, and
|
|
542
|
+
creation times.
|
|
543
|
+
"""
|
|
544
|
+
cur = self.conn.cursor()
|
|
545
|
+
cur.execute(
|
|
546
|
+
"SELECT id, name, path, created_at FROM snapshots ORDER BY created_at DESC"
|
|
547
|
+
)
|
|
548
|
+
rows = cur.fetchall()
|
|
549
|
+
return [
|
|
550
|
+
{"id": row[0], "name": row[1], "path": row[2], "created_at": row[3]}
|
|
551
|
+
for row in rows
|
|
552
|
+
]
|
|
553
|
+
|
|
554
|
+
def diff_snapshots(self, name1: str, name2: str, force: bool = False):
|
|
555
|
+
"""Compare two snapshots and return a diff summary.
|
|
556
|
+
|
|
557
|
+
:param name1: The name of the first snapshot.
|
|
558
|
+
:param name2: The name of the second snapshot.
|
|
559
|
+
:param force: If True, force the diff even if root paths don't match.
|
|
560
|
+
:return: A dictionary with lists of added, removed, changed, and moved files.
|
|
561
|
+
"""
|
|
562
|
+
cur = self.conn.cursor()
|
|
563
|
+
|
|
564
|
+
# get snapshot IDs
|
|
565
|
+
cur.execute("SELECT id, path FROM snapshots WHERE name = ?", (name1,))
|
|
566
|
+
row1 = cur.fetchone()
|
|
567
|
+
cur.execute("SELECT id, path FROM snapshots WHERE name = ?", (name2,))
|
|
568
|
+
row2 = cur.fetchone()
|
|
569
|
+
|
|
570
|
+
if not row1 or not row2:
|
|
571
|
+
raise ValueError(f"Snapshot not found: {name1 if not row1 else name2}")
|
|
572
|
+
|
|
573
|
+
id1, root1 = row1
|
|
574
|
+
id2, root2 = row2
|
|
575
|
+
|
|
576
|
+
if root1 != root2 and not force:
|
|
577
|
+
raise ValueError(f"Snapshots have different paths: {root1} != {root2}")
|
|
578
|
+
|
|
579
|
+
diff = {"added": [], "removed": [], "changed": []}
|
|
580
|
+
|
|
581
|
+
# changed files: same path in both, but different hash
|
|
582
|
+
changed_paths = set()
|
|
583
|
+
cur.execute(
|
|
584
|
+
"""
|
|
585
|
+
SELECT f1.path
|
|
586
|
+
FROM snapshot_files sf1
|
|
587
|
+
JOIN files f1 ON f1.id = sf1.file_id
|
|
588
|
+
JOIN snapshot_files sf2 ON sf2.snapshot_id = ?
|
|
589
|
+
JOIN files f2 ON f2.id = sf2.file_id
|
|
590
|
+
WHERE sf1.snapshot_id = ?
|
|
591
|
+
AND f1.path = f2.path
|
|
592
|
+
AND f1.hash IS NOT NULL AND f2.hash IS NOT NULL
|
|
593
|
+
AND f1.hash != f2.hash
|
|
594
|
+
""",
|
|
595
|
+
(id2, id1),
|
|
596
|
+
)
|
|
597
|
+
for row in cur.fetchall():
|
|
598
|
+
changed_paths.add(row[0])
|
|
599
|
+
diff["changed"].append(row[0])
|
|
600
|
+
|
|
601
|
+
# added files: in v2, not in v1 (ecluding changed paths)
|
|
602
|
+
cur.execute(
|
|
603
|
+
"""
|
|
604
|
+
SELECT f.path FROM snapshot_files sf2
|
|
605
|
+
JOIN files f ON f.id = sf2.file_id
|
|
606
|
+
WHERE sf2.snapshot_id = ?
|
|
607
|
+
AND sf2.file_id NOT IN (
|
|
608
|
+
SELECT file_id FROM snapshot_files WHERE snapshot_id = ?
|
|
609
|
+
)
|
|
610
|
+
""",
|
|
611
|
+
(id2, id1),
|
|
612
|
+
)
|
|
613
|
+
for row in cur.fetchall():
|
|
614
|
+
if row[0] not in changed_paths:
|
|
615
|
+
diff["added"].append(row[0])
|
|
616
|
+
|
|
617
|
+
# removed files: in v1, not in v2 (ecluding changed paths)
|
|
618
|
+
cur.execute(
|
|
619
|
+
"""
|
|
620
|
+
SELECT f.path FROM snapshot_files sf1
|
|
621
|
+
JOIN files f ON f.id = sf1.file_id
|
|
622
|
+
WHERE sf1.snapshot_id = ?
|
|
623
|
+
AND sf1.file_id NOT IN (
|
|
624
|
+
SELECT file_id FROM snapshot_files WHERE snapshot_id = ?
|
|
625
|
+
)
|
|
626
|
+
""",
|
|
627
|
+
(id1, id2),
|
|
628
|
+
)
|
|
629
|
+
for row in cur.fetchall():
|
|
630
|
+
if row[0] not in changed_paths:
|
|
631
|
+
diff["removed"].append(row[0])
|
|
632
|
+
|
|
633
|
+
# normalize everything into sets for deduplication
|
|
634
|
+
added_set = set(diff["added"])
|
|
635
|
+
removed_set = set(diff["removed"])
|
|
636
|
+
changed_set = set(diff["changed"])
|
|
637
|
+
|
|
638
|
+
# if a file appears in both added and removed, but not changed, it's a false positive
|
|
639
|
+
both_added_and_removed = added_set & removed_set
|
|
640
|
+
|
|
641
|
+
# remove those from both lists — likely due to mtime/hash/id issues
|
|
642
|
+
diff["added"] = sorted(list(added_set - both_added_and_removed))
|
|
643
|
+
diff["removed"] = sorted(list(removed_set - both_added_and_removed))
|
|
644
|
+
|
|
645
|
+
# if it was truly changed, let it appear only in 'changed'
|
|
646
|
+
diff["changed"] = sorted(list(changed_set))
|
|
647
|
+
|
|
648
|
+
return diff
|
|
649
|
+
|
|
650
|
+
def commit(self):
|
|
651
|
+
"""Commit any pending changes to the database."""
|
|
652
|
+
self.conn.commit()
|
|
653
|
+
|
|
654
|
+
def close(self):
|
|
655
|
+
"""Close the database connection."""
|
|
656
|
+
self.conn.commit()
|
|
657
|
+
self.conn.close()
|