flexlock 0.8.2__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.
- flexlock/__init__.py +87 -0
- flexlock/api.py +1444 -0
- flexlock/backends/__init__.py +11 -0
- flexlock/backends/base.py +41 -0
- flexlock/backends/pbs.py +219 -0
- flexlock/backends/slurm.py +333 -0
- flexlock/cli.py +953 -0
- flexlock/config.py +121 -0
- flexlock/context.py +7 -0
- flexlock/data_hash.py +310 -0
- flexlock/debug.py +473 -0
- flexlock/diff.py +253 -0
- flexlock/diff_cli.py +155 -0
- flexlock/exceptions.py +50 -0
- flexlock/export.py +134 -0
- flexlock/fingerprint.py +129 -0
- flexlock/flexcli.py +163 -0
- flexlock/freeze.py +291 -0
- flexlock/git_utils.py +196 -0
- flexlock/index.py +300 -0
- flexlock/load_stage.py +60 -0
- flexlock/mlflow.py +192 -0
- flexlock/parallel.py +378 -0
- flexlock/query.py +739 -0
- flexlock/report.py +78 -0
- flexlock/report_template.html +246 -0
- flexlock/resolvers.py +248 -0
- flexlock/run_cli.py +44 -0
- flexlock/run_record.py +173 -0
- flexlock/runner.py +571 -0
- flexlock/save_dir.py +232 -0
- flexlock/skills/flexlock-new-stage/SKILL.md +57 -0
- flexlock/skills/flexlock-report/SKILL.md +50 -0
- flexlock/skills/flexlock-run-and-watch/SKILL.md +60 -0
- flexlock/skills/flexlock-survey/SKILL.md +61 -0
- flexlock/snapshot.py +245 -0
- flexlock/status_cli.py +299 -0
- flexlock/taskdb.py +685 -0
- flexlock/utils.py +710 -0
- flexlock/worker.py +228 -0
- flexlock/worker_cli.py +143 -0
- flexlock-0.8.2.dist-info/METADATA +357 -0
- flexlock-0.8.2.dist-info/RECORD +45 -0
- flexlock-0.8.2.dist-info/WHEEL +4 -0
- flexlock-0.8.2.dist-info/entry_points.txt +7 -0
flexlock/config.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Configuration and constants for FlexLock.
|
|
2
|
+
|
|
3
|
+
This module defines default values and environment variable overrides.
|
|
4
|
+
All environment variables are prefixed with FLEXLOCK_.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_env_int(key: str, default: int) -> int:
|
|
11
|
+
"""Get integer from environment variable."""
|
|
12
|
+
value = os.environ.get(key)
|
|
13
|
+
if value is None:
|
|
14
|
+
return default
|
|
15
|
+
try:
|
|
16
|
+
return int(value)
|
|
17
|
+
except ValueError:
|
|
18
|
+
return default
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_env_float(key: str, default: float) -> float:
|
|
22
|
+
"""Get float from environment variable."""
|
|
23
|
+
value = os.environ.get(key)
|
|
24
|
+
if value is None:
|
|
25
|
+
return default
|
|
26
|
+
try:
|
|
27
|
+
return float(value)
|
|
28
|
+
except ValueError:
|
|
29
|
+
return default
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_env_bool(key: str, default: bool) -> bool:
|
|
33
|
+
"""Get boolean from environment variable."""
|
|
34
|
+
value = os.environ.get(key, "").lower()
|
|
35
|
+
if value in ("1", "true", "yes", "on"):
|
|
36
|
+
return True
|
|
37
|
+
elif value in ("0", "false", "no", "off"):
|
|
38
|
+
return False
|
|
39
|
+
return default
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ==================== Parallel Execution ====================
|
|
43
|
+
|
|
44
|
+
# Poll interval for checking task status (seconds)
|
|
45
|
+
POLL_INTERVAL = get_env_int("FLEXLOCK_POLL_INTERVAL", 10)
|
|
46
|
+
|
|
47
|
+
# Log frequency for progress updates (seconds)
|
|
48
|
+
LOG_FREQUENCY = get_env_int("FLEXLOCK_LOG_FREQUENCY", 15)
|
|
49
|
+
|
|
50
|
+
# Default number of parallel jobs
|
|
51
|
+
DEFAULT_N_JOBS = get_env_int("FLEXLOCK_DEFAULT_N_JOBS", 1)
|
|
52
|
+
|
|
53
|
+
# Default timeout for waiting on HPC jobs (seconds, None = no timeout)
|
|
54
|
+
_timeout_str = os.environ.get("FLEXLOCK_DEFAULT_TIMEOUT")
|
|
55
|
+
DEFAULT_TIMEOUT = int(_timeout_str) if _timeout_str else 3600
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ==================== Smart Run / Caching ====================
|
|
59
|
+
|
|
60
|
+
# Whether to warn when smart_run=True but search_dirs=None
|
|
61
|
+
WARN_SMART_RUN_NO_SEARCH_DIRS = get_env_bool("FLEXLOCK_WARN_SMART_RUN", True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ==================== Timestamp Format ====================
|
|
65
|
+
|
|
66
|
+
# Standard timestamp format for directory names
|
|
67
|
+
# ISO 8601 compatible: YYYY-MM-DDTHH-MM-SS
|
|
68
|
+
TIMESTAMP_FORMAT = os.environ.get("FLEXLOCK_TIMESTAMP_FORMAT", "%Y-%m-%dT%H-%M-%S")
|
|
69
|
+
|
|
70
|
+
# Timestamp format for snapshots (ISO 8601 with microseconds)
|
|
71
|
+
SNAPSHOT_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S.%f"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ==================== Database ====================
|
|
75
|
+
|
|
76
|
+
# Database filename suffix
|
|
77
|
+
DB_FILENAME_SUFFIX = ".tasks.db"
|
|
78
|
+
|
|
79
|
+
# Attempts for DB write transactions when SQLite reports "database is locked"
|
|
80
|
+
# (jittered exponential backoff between attempts, on top of busy_timeout).
|
|
81
|
+
DB_RETRY_ATTEMPTS = get_env_int("FLEXLOCK_DB_RETRY_ATTEMPTS", 8)
|
|
82
|
+
|
|
83
|
+
# Cap on the backoff delay between retry attempts (seconds)
|
|
84
|
+
DB_RETRY_MAX_BACKOFF = get_env_float("FLEXLOCK_DB_RETRY_MAX_BACKOFF", 5.0)
|
|
85
|
+
|
|
86
|
+
# Number of tasks a worker claims per DB transaction. Raise this (e.g. 16-64)
|
|
87
|
+
# when running hundreds of workers against one DB on a shared filesystem:
|
|
88
|
+
# claim and finish write-lock traffic both drop by this factor. Trade-off: a
|
|
89
|
+
# worker that dies strands up to this many claimed tasks until the controller
|
|
90
|
+
# reconciles orphans / --reclaim resets them.
|
|
91
|
+
CLAIM_BATCH = get_env_int("FLEXLOCK_CLAIM_BATCH", 1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ==================== Pre-flight checks ====================
|
|
95
|
+
|
|
96
|
+
# When set, a worker verifies it can actually create a CUDA context before
|
|
97
|
+
# claiming any task. Catches unhealthy GPU nodes (Fabric Manager down,
|
|
98
|
+
# driver/kernel mismatch → cudaErrorSystemNotReady/802) up front with a clear
|
|
99
|
+
# log, instead of an opaque traceback deep inside the user's training.
|
|
100
|
+
PREFLIGHT_CUDA = get_env_bool("FLEXLOCK_PREFLIGHT_CUDA", False)
|
|
101
|
+
|
|
102
|
+
# Default database filename pattern
|
|
103
|
+
DB_FILENAME_PATTERN = "run.lock{suffix}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ==================== Logging ====================
|
|
107
|
+
|
|
108
|
+
# Whether to configure logging by default
|
|
109
|
+
CONFIGURE_LOGGING = get_env_bool("FLEXLOCK_CONFIGURE_LOGGING", True)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ==================== Debug Mode ====================
|
|
113
|
+
|
|
114
|
+
# Debug mode enabled
|
|
115
|
+
DEBUG = get_env_bool("FLEXLOCK_DEBUG", False)
|
|
116
|
+
|
|
117
|
+
# Debug mode disabled (takes precedence)
|
|
118
|
+
NODEBUG = get_env_bool("FLEXLOCK_NODEBUG", False)
|
|
119
|
+
|
|
120
|
+
# Debug strategy: 'auto', 'pdb', or 'inject'
|
|
121
|
+
DEBUG_STRATEGY = os.environ.get("FLEXLOCK_DEBUG_STRATEGY", "auto")
|
flexlock/context.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Shared context for a FlexLock run."""
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
|
|
5
|
+
# This context will hold information gathered during a run, like the git commit hash.
|
|
6
|
+
# It is initialized with an empty dictionary.
|
|
7
|
+
run_context: ContextVar[dict] = ContextVar("run_context", default={})
|
flexlock/data_hash.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Data hashing utilities for FlexLock."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import xxhash
|
|
8
|
+
import hashlib
|
|
9
|
+
from joblib import Parallel, delayed
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
|
|
12
|
+
from . import config
|
|
13
|
+
|
|
14
|
+
# Hash-format version. Bump when the on-disk hash algorithm changes so stale
|
|
15
|
+
# rows in an old cache file are never returned as valid hits. v2: dirhash now
|
|
16
|
+
# folds each file's relative path into the digest (previously content-only).
|
|
17
|
+
HASH_VERSION = 2
|
|
18
|
+
|
|
19
|
+
# --- Cache Configuration ---
|
|
20
|
+
CACHE_DIR = (
|
|
21
|
+
Path(
|
|
22
|
+
os.environ.get(
|
|
23
|
+
"FLEXLOCK_CACHE", os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
/ "flexlock"
|
|
27
|
+
)
|
|
28
|
+
CACHE_DB = CACHE_DIR / f"hashes_v{HASH_VERSION}.db"
|
|
29
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
DEFAULT_DIR_FILE_LIMIT = os.environ.get("FLEXLOCK_DIR_FILE_LIMIT", 1000)
|
|
31
|
+
|
|
32
|
+
# Thread-local storage for database connections
|
|
33
|
+
_thread_local_conns = threading.local()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def _get_db():
|
|
38
|
+
"""
|
|
39
|
+
A thread-safe context manager for SQLite database connections.
|
|
40
|
+
|
|
41
|
+
This function maintains a cache of connections per thread. A new connection
|
|
42
|
+
is created for each unique database path and reused for subsequent calls
|
|
43
|
+
with the same path within that thread.
|
|
44
|
+
"""
|
|
45
|
+
# Use the absolute path as a reliable key for the connections dictionary.
|
|
46
|
+
db_path_str = str(CACHE_DB.resolve())
|
|
47
|
+
|
|
48
|
+
# Initialize the connections dictionary for the current thread if it doesn't exist.
|
|
49
|
+
if not hasattr(_thread_local_conns, "conns"):
|
|
50
|
+
_thread_local_conns.conns = {}
|
|
51
|
+
|
|
52
|
+
# Check if a connection for this specific db_path already exists in the thread's cache.
|
|
53
|
+
if db_path_str not in _thread_local_conns.conns:
|
|
54
|
+
# If not, create a new connection and add it to the cache.
|
|
55
|
+
try:
|
|
56
|
+
c = sqlite3.connect(db_path_str, check_same_thread=False)
|
|
57
|
+
# Set PRAGMA for better performance and concurrency.
|
|
58
|
+
c.execute("PRAGMA journal_mode=WAL")
|
|
59
|
+
c.execute("PRAGMA busy_timeout=15000")
|
|
60
|
+
c.execute(
|
|
61
|
+
"PRAGMA foreign_keys=ON"
|
|
62
|
+
) # Good practice to enforce foreign key constraints
|
|
63
|
+
c.execute(
|
|
64
|
+
"""
|
|
65
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
66
|
+
path TEXT PRIMARY KEY,
|
|
67
|
+
mtime REAL,
|
|
68
|
+
file_count INTEGER,
|
|
69
|
+
latest_mtime REAL,
|
|
70
|
+
hash TEXT,
|
|
71
|
+
is_dir INTEGER
|
|
72
|
+
)
|
|
73
|
+
"""
|
|
74
|
+
)
|
|
75
|
+
_thread_local_conns.conns[db_path_str] = c
|
|
76
|
+
except sqlite3.Error as e:
|
|
77
|
+
print(f"Error connecting to database {db_path_str}: {e}")
|
|
78
|
+
raise
|
|
79
|
+
|
|
80
|
+
# Yield the connection from the thread's cache.
|
|
81
|
+
try:
|
|
82
|
+
yield _thread_local_conns.conns[db_path_str]
|
|
83
|
+
finally:
|
|
84
|
+
# Don't close the connection since we're caching it for reuse
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _hash_file_content(path):
|
|
89
|
+
"""Hashes a single file using XXHash."""
|
|
90
|
+
hasher = xxhash.xxh64()
|
|
91
|
+
with open(path, "rb") as f:
|
|
92
|
+
while chunk := f.read(65536):
|
|
93
|
+
hasher.update(chunk)
|
|
94
|
+
return hasher.hexdigest()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _get_dir_stats(path: Path, limit: int):
|
|
98
|
+
"""
|
|
99
|
+
Walks a directory to get the file count and the latest modification time.
|
|
100
|
+
|
|
101
|
+
If the file count exceeds the limit, it returns (limit + 1, 0) to signal
|
|
102
|
+
that the directory is "large".
|
|
103
|
+
"""
|
|
104
|
+
count = 0
|
|
105
|
+
latest_mtime = path.stat().st_mtime
|
|
106
|
+
|
|
107
|
+
for root, _, files in os.walk(path):
|
|
108
|
+
count += len(files)
|
|
109
|
+
if count > limit:
|
|
110
|
+
return count, 0 # Exceeded limit, fallback mode
|
|
111
|
+
|
|
112
|
+
for name in files:
|
|
113
|
+
filepath = Path(root) / name
|
|
114
|
+
try:
|
|
115
|
+
mtime = filepath.stat().st_mtime
|
|
116
|
+
if mtime > latest_mtime:
|
|
117
|
+
latest_mtime = mtime
|
|
118
|
+
except OSError:
|
|
119
|
+
# File might be a broken symlink, etc.
|
|
120
|
+
pass
|
|
121
|
+
return count, latest_mtime
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def dirhash(
|
|
125
|
+
path, match=None, ignore=None, jobs=1, algorithm=hashlib.md5, chunk_size=65536
|
|
126
|
+
):
|
|
127
|
+
"""
|
|
128
|
+
Computes a hash of the directory content using pathlib.glob for filtering.
|
|
129
|
+
"""
|
|
130
|
+
base_path = Path(path)
|
|
131
|
+
if not base_path.is_dir():
|
|
132
|
+
raise ValueError(f"'{path}' is not a valid directory.")
|
|
133
|
+
|
|
134
|
+
# Default match to all files recursively if not specified
|
|
135
|
+
match_pattern = match if match is not None else "**/*"
|
|
136
|
+
if isinstance(match_pattern, str):
|
|
137
|
+
match_pattern = [match_pattern]
|
|
138
|
+
|
|
139
|
+
ignore_patterns = ignore if ignore is not None else []
|
|
140
|
+
if isinstance(ignore_patterns, str):
|
|
141
|
+
ignore_patterns = [ignore_patterns]
|
|
142
|
+
|
|
143
|
+
import glob
|
|
144
|
+
from pathlib import Path as p_path
|
|
145
|
+
|
|
146
|
+
# Find all files matching patterns
|
|
147
|
+
files_to_hash = []
|
|
148
|
+
for pattern in match_pattern:
|
|
149
|
+
files_to_hash.extend(base_path.glob(pattern))
|
|
150
|
+
|
|
151
|
+
files_to_hash = [f for f in files_to_hash if f.is_file()]
|
|
152
|
+
|
|
153
|
+
# Apply ignore patterns
|
|
154
|
+
final_files = []
|
|
155
|
+
for f in files_to_hash:
|
|
156
|
+
should_ignore = False
|
|
157
|
+
for pattern in ignore_patterns:
|
|
158
|
+
if f.match(pattern):
|
|
159
|
+
should_ignore = True
|
|
160
|
+
break
|
|
161
|
+
if not should_ignore:
|
|
162
|
+
final_files.append(f)
|
|
163
|
+
|
|
164
|
+
if not final_files:
|
|
165
|
+
return algorithm().hexdigest()
|
|
166
|
+
|
|
167
|
+
def _hash_file(filepath, algorithm, chunk_size):
|
|
168
|
+
"""Hashes a single file."""
|
|
169
|
+
hasher = algorithm()
|
|
170
|
+
with open(filepath, "rb") as f:
|
|
171
|
+
while True:
|
|
172
|
+
data = f.read(chunk_size)
|
|
173
|
+
if not data:
|
|
174
|
+
break
|
|
175
|
+
hasher.update(data)
|
|
176
|
+
return hasher.hexdigest()
|
|
177
|
+
|
|
178
|
+
content_hashes = Parallel(n_jobs=jobs)(
|
|
179
|
+
delayed(_hash_file)(str(f), algorithm, chunk_size) for f in final_files
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Fold the file's path into the digest so that two directories with
|
|
183
|
+
# identical file *contents* under different names hash differently, and so
|
|
184
|
+
# that swapping contents between two paths changes the hash.
|
|
185
|
+
entries = [
|
|
186
|
+
f"{f.relative_to(base_path).as_posix()}\0{h}"
|
|
187
|
+
for f, h in zip(final_files, content_hashes)
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
final_hasher = algorithm()
|
|
191
|
+
for entry in sorted(entries):
|
|
192
|
+
final_hasher.update(entry.encode("utf-8"))
|
|
193
|
+
|
|
194
|
+
return final_hasher.hexdigest()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def hash_data(
|
|
198
|
+
path,
|
|
199
|
+
match=None,
|
|
200
|
+
ignore=None,
|
|
201
|
+
jobs=4,
|
|
202
|
+
algorithm=xxhash.xxh64,
|
|
203
|
+
chunk_size=2**18,
|
|
204
|
+
use_cache=True,
|
|
205
|
+
):
|
|
206
|
+
"""
|
|
207
|
+
Computes a hash for a file or a directory, using an SQLite cache to avoid re-computation.
|
|
208
|
+
"""
|
|
209
|
+
path = Path(path).resolve()
|
|
210
|
+
# Honour the ``use_cache`` argument; the env var can only *force* caching
|
|
211
|
+
# off (it never re-enables a cache the caller explicitly disabled).
|
|
212
|
+
use_cache = use_cache and not config.get_env_bool("FLEXLOCK_NO_CACHE", False)
|
|
213
|
+
dir_file_limit = int(
|
|
214
|
+
os.environ.get("FLEXLOCK_CACHE_DIR_FILE_LIMIT", DEFAULT_DIR_FILE_LIMIT)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if use_cache:
|
|
218
|
+
with _get_db() as conn:
|
|
219
|
+
cursor = conn.cursor()
|
|
220
|
+
|
|
221
|
+
if path.is_file():
|
|
222
|
+
# Check cache for file
|
|
223
|
+
cursor.execute(
|
|
224
|
+
"SELECT hash, mtime FROM cache WHERE path=? AND is_dir=0",
|
|
225
|
+
(str(path),),
|
|
226
|
+
)
|
|
227
|
+
row = cursor.fetchone()
|
|
228
|
+
|
|
229
|
+
if row:
|
|
230
|
+
cached_hash, cached_mtime = row
|
|
231
|
+
current_mtime = path.stat().st_mtime
|
|
232
|
+
if cached_mtime == current_mtime:
|
|
233
|
+
return cached_hash
|
|
234
|
+
elif path.is_dir():
|
|
235
|
+
# Check cache for directory
|
|
236
|
+
cursor.execute(
|
|
237
|
+
"SELECT hash, mtime, file_count, latest_mtime FROM cache WHERE path=? AND is_dir=1",
|
|
238
|
+
(str(path),),
|
|
239
|
+
)
|
|
240
|
+
row = cursor.fetchone()
|
|
241
|
+
|
|
242
|
+
if row:
|
|
243
|
+
(
|
|
244
|
+
cached_hash,
|
|
245
|
+
cached_mtime,
|
|
246
|
+
cached_file_count,
|
|
247
|
+
cached_latest_mtime,
|
|
248
|
+
) = row
|
|
249
|
+
file_count, latest_mtime = _get_dir_stats(path, dir_file_limit)
|
|
250
|
+
|
|
251
|
+
if file_count > dir_file_limit:
|
|
252
|
+
# Large directory fallback
|
|
253
|
+
current_mtime = path.stat().st_mtime
|
|
254
|
+
if cached_mtime == current_mtime:
|
|
255
|
+
return cached_hash
|
|
256
|
+
else:
|
|
257
|
+
if (
|
|
258
|
+
cached_file_count == file_count
|
|
259
|
+
and cached_latest_mtime == latest_mtime
|
|
260
|
+
):
|
|
261
|
+
return cached_hash
|
|
262
|
+
|
|
263
|
+
# If not in cache or cache is invalid/disabled, compute the hash
|
|
264
|
+
if not path.exists():
|
|
265
|
+
raise FileNotFoundError(f"The specified path does not exist: {path}")
|
|
266
|
+
|
|
267
|
+
new_hash = None
|
|
268
|
+
if path.is_file():
|
|
269
|
+
new_hash = _hash_file_content(path)
|
|
270
|
+
elif path.is_dir():
|
|
271
|
+
new_hash = dirhash(
|
|
272
|
+
path,
|
|
273
|
+
match=match,
|
|
274
|
+
ignore=ignore,
|
|
275
|
+
jobs=jobs,
|
|
276
|
+
algorithm=algorithm,
|
|
277
|
+
chunk_size=chunk_size,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if new_hash is None:
|
|
281
|
+
raise ValueError(f"Could not compute hash for path: {path}")
|
|
282
|
+
|
|
283
|
+
# Update and save the cache if enabled
|
|
284
|
+
if use_cache:
|
|
285
|
+
with _get_db() as conn:
|
|
286
|
+
cursor = conn.cursor()
|
|
287
|
+
if path.is_file():
|
|
288
|
+
mtime = path.stat().st_mtime
|
|
289
|
+
cursor.execute(
|
|
290
|
+
"INSERT OR REPLACE INTO cache VALUES (?, ?, NULL, NULL, ?, 0)",
|
|
291
|
+
(str(path), mtime, new_hash),
|
|
292
|
+
)
|
|
293
|
+
elif path.is_dir():
|
|
294
|
+
file_count, latest_mtime = _get_dir_stats(path, dir_file_limit)
|
|
295
|
+
mtime = path.stat().st_mtime
|
|
296
|
+
if file_count > dir_file_limit:
|
|
297
|
+
# For large directories, use just the directory's mtime
|
|
298
|
+
cursor.execute(
|
|
299
|
+
"INSERT OR REPLACE INTO cache VALUES (?, ?, NULL, NULL, ?, 1)",
|
|
300
|
+
(str(path), mtime, new_hash),
|
|
301
|
+
)
|
|
302
|
+
else:
|
|
303
|
+
# For smaller directories, cache more detailed stats
|
|
304
|
+
cursor.execute(
|
|
305
|
+
"INSERT OR REPLACE INTO cache VALUES (?, ?, ?, ?, ?, 1)",
|
|
306
|
+
(str(path), mtime, file_count, latest_mtime, new_hash),
|
|
307
|
+
)
|
|
308
|
+
conn.commit()
|
|
309
|
+
|
|
310
|
+
return new_hash
|