Source code for app.sources.local
"""LocalPathSource — references a directory the user already owns.
We do NOT copy or symlink (Windows symlinks need admin). We materialize
**by reference**: pycolmap is pointed at the user's path. Drift detection
uses a fingerprint of (path, size, mtime, sample-hash of head/mid/tail
1 MiB) — cheap, stable, catches "user replaced files under us."
"""
from __future__ import annotations
import hashlib
import os
from dataclasses import dataclass
from pathlib import Path
from app.core.errors import StorageError
from app.sources.base import MaterializedImage
DEFAULT_EXTENSIONS = (
".jpg",
".jpeg",
".png",
".tif",
".tiff",
".bmp",
".webp",
".heic",
".heif",
)
SAMPLE_BYTES = 1 << 20 # 1 MiB
[docs]
@dataclass
class LocalPathSource:
root: Path
kind: str = "local"
recursive: bool = True
extensions: tuple[str, ...] = DEFAULT_EXTENSIONS
def __post_init__(self) -> None:
self.root = Path(self.root)
if not self.root.is_dir():
raise StorageError(f"Local source root not a directory: {self.root}")
def _iter_image_paths(self) -> list[Path]:
it = self.root.rglob("*") if self.recursive else self.root.glob("*")
out: list[Path] = []
for p in it:
if p.is_file() and p.suffix.lower() in self.extensions:
out.append(p)
out.sort()
return out
[docs]
def fingerprint(self) -> dict:
files = []
for p in self._iter_image_paths():
st = p.stat()
files.append(
{
"rel": str(p.relative_to(self.root)).replace("\\", "/"),
"size": st.st_size,
"mtime_ns": st.st_mtime_ns,
"sample": _sample_hash(p, st.st_size),
}
)
return {
"kind": self.kind,
"root": str(self.root.resolve()),
"files": files,
}
[docs]
def materialize(self, into: Path | None = None) -> list[MaterializedImage]:
# `into` is intentionally ignored — the user owns the bytes.
out: list[MaterializedImage] = []
for p in self._iter_image_paths():
out.append(
MaterializedImage(
name=str(p.relative_to(self.root)).replace("\\", "/"),
abs_path=p,
content_sha=None,
)
)
return out
def _sample_hash(p: Path, size: int) -> str:
"""Hash the head, middle, and tail 1 MiB. Fixed-cost regardless of
file size — good enough to detect content mutation cheaply."""
h = hashlib.sha256()
if size <= 3 * SAMPLE_BYTES:
with p.open("rb") as fh:
h.update(fh.read())
return h.hexdigest()
with p.open("rb") as fh:
head = fh.read(SAMPLE_BYTES)
fh.seek((size // 2) - SAMPLE_BYTES // 2, os.SEEK_SET)
mid = fh.read(SAMPLE_BYTES)
fh.seek(-SAMPLE_BYTES, os.SEEK_END)
tail = fh.read(SAMPLE_BYTES)
h.update(head)
h.update(mid)
h.update(tail)
return h.hexdigest()