"""Parts data layer. Loads the parts CSV into a local SQLite cache (one table, one meta row), and provides a fuzzy search API powered by rapidfuzz. Design notes ------------ - We rebuild the table from scratch whenever the CSV's mtime changes. The CSV is ~2k rows, so a full reload takes well under a second and avoids any diff/upsert bugs. - Fuzzy search runs entirely in memory over a tiny tuple of (sku, description, interchange) for each row. With ~2k rows this is fast enough to run on every keystroke without debouncing. - We expose Part as a plain dict so the UI layer doesn't need to import this module's internals. """ from __future__ import annotations import csv import logging import re import sqlite3 import threading from typing import Iterable try: import rapidfuzz.fuzz as fuzz import rapidfuzz.process as process _HAS_RAPIDFUZZ = True except ImportError: _HAS_RAPIDFUZZ = False log = logging.getLogger(__name__) # ── Make detection ──────────────────────────────────────────────────────────── _MAKE_PATTERNS: dict[str, list[str]] = { "Acura": ["Acura"], "Audi": ["Audi"], "BMW": ["BMW", "B.M.W"], "Buick": ["Buick"], "Cadillac": ["Cadillac", "Cad"], "Chevrolet": ["Chevrolet", "Chevy", "Chev"], "Chrysler": ["Chrysler"], "Dodge": ["Dodge"], "Ford": ["Ford"], "GMC": ["GMC"], "Honda": ["Honda"], "Hyundai": ["Hyundai"], "Infiniti": ["Infiniti", "Infinity"], "Isuzu": ["Isuzu"], "Jeep": ["Jeep"], "Kia": ["Kia"], "Lexus": ["Lexus"], "Lincoln": ["Lincoln"], "Mazda": ["Mazda"], "Mercedes-Benz": ["Mercedes", "Mercedes-Benz", "Benz"], "Mercury": ["Mercury"], "Mitsubishi": ["Mitsubishi", "Mitsu"], "Nissan": ["Nissan"], "Oldsmobile": ["Oldsmobile", "Olds"], "Plymouth": ["Plymouth"], "Pontiac": ["Pontiac"], "Ram": ["Ram"], "Saturn": ["Saturn"], "Subaru": ["Subaru"], "Suzuki": ["Suzuki"], "Toyota": ["Toyota"], "Volkswagen": ["Volkswagen", "VW"], "Volvo": ["Volvo"], } _COMPILED_MAKE_PATTERNS: dict[str, re.Pattern] = { make: re.compile( r"\b(" + "|".join(re.escape(p) for p in patterns) + r")\b", re.IGNORECASE, ) for make, patterns in _MAKE_PATTERNS.items() } def _detect_makes(description: str) -> list[str]: """Return the canonical car makes that appear in `description`.""" found: list[str] = [] for make, pat in _COMPILED_MAKE_PATTERNS.items(): if pat.search(description): found.append(make) return found # ── Model detection ─────────────────────────────────────────────────────────── _MODEL_PATTERNS: dict[str, list[str]] = { # Honda "Accord": ["Accord"], "Civic": ["Civic"], "CR-V": ["CR-V", "CRV"], "Fit": ["Fit"], "HR-V": ["HR-V", "HRV"], "Odyssey": ["Odyssey"], "Pilot": ["Pilot"], "Ridgeline": ["Ridgeline"], # Toyota "4Runner": ["4Runner", "4-Runner"], "Camry": ["Camry"], "Corolla": ["Corolla"], "Highlander": ["Highlander"], "Prius": ["Prius"], "RAV4": ["RAV4", "RAV-4"], "Sienna": ["Sienna"], "Tacoma": ["Tacoma"], "Tundra": ["Tundra"], # Ford "Bronco": ["Bronco"], "Escape": ["Escape"], "Explorer": ["Explorer"], "F-150": ["F-150", "F150", "F 150"], "F-250": ["F-250", "F250"], "F-350": ["F-350", "F350"], "Focus": ["Focus"], "Fusion": ["Fusion"], "Mustang": ["Mustang"], "Ranger": ["Ranger"], # Chevy / GMC "Blazer": ["Blazer"], "Colorado": ["Colorado"], "Equinox": ["Equinox"], "Malibu": ["Malibu"], "Silverado": ["Silverado"], "Suburban": ["Suburban"], "Tahoe": ["Tahoe"], "Traverse": ["Traverse"], "Trax": ["Trax"], "Sierra": ["Sierra"], "Yukon": ["Yukon"], # Dodge / Ram / Jeep / Chrysler "Challenger": ["Challenger"], "Charger": ["Charger"], "Durango": ["Durango"], "Grand Cherokee": ["Grand Cherokee"], "Wrangler": ["Wrangler"], "Ram 1500": ["Ram 1500", "1500"], "Ram 2500": ["Ram 2500", "2500"], # Nissan "Altima": ["Altima"], "Frontier": ["Frontier"], "Maxima": ["Maxima"], "Murano": ["Murano"], "Pathfinder": ["Pathfinder"], "Rogue": ["Rogue"], "Sentra": ["Sentra"], "Titan": ["Titan"], "Versa": ["Versa"], # Hyundai / Kia "Elantra": ["Elantra"], "Santa Fe": ["Santa Fe"], "Sonata": ["Sonata"], "Tucson": ["Tucson"], "Soul": ["Soul"], "Sportage": ["Sportage"], "Sorento": ["Sorento"], "Telluride": ["Telluride"], # Subaru "Forester": ["Forester"], "Impreza": ["Impreza"], "Legacy": ["Legacy"], "Outback": ["Outback"], "WRX": ["WRX"], } _COMPILED_MODEL_PATTERNS: dict[str, re.Pattern] = { model: re.compile( r"\b(" + "|".join(re.escape(p) for p in patterns) + r")\b", re.IGNORECASE, ) for model, patterns in _MODEL_PATTERNS.items() } def _detect_models(description: str) -> list[str]: """Return the canonical models found in `description`.""" found: list[str] = [] for model, pat in _COMPILED_MODEL_PATTERNS.items(): if pat.search(description): found.append(model) return found # ── Side detection ──────────────────────────────────────────────────────────── _SIDE_SUFFIX_RE = re.compile( r"[\s_-]?(LH?|RH?|LEFT|RIGHT|BOTH|DRIVER|PASSENGER|DR|PASS)\b", re.IGNORECASE, ) def _detect_side(sku: str) -> str: """Return 'left', 'right', 'both', or 'unknown' based on the SKU suffix.""" m = _SIDE_SUFFIX_RE.search(sku) if not m: return "unknown" token = m.group(1).upper() if token in ("L", "LH", "LEFT", "DRIVER", "DR"): return "left" if token in ("R", "RH", "RIGHT", "PASSENGER", "PASS"): return "right" if token in ("BOTH",): return "both" return "unknown" # ── SQLite schema ───────────────────────────────────────────────────────────── COLUMNS = ( "stock_number", "description", "customer_price", "list_price", "part_type", "grade", "yard_location", "available", "quantity", "bin_location", "interchange", "warranty_type", "warranty_length", "warranty_info", "image_url", ) _CSV_TO_COL: dict[str, str] = { "STOCK NUMBER": "stock_number", "DESCRIPTION": "description", "Customer Price": "customer_price", "LIST": "list_price", "PART TYPE": "part_type", "GRADE": "grade", "YARD LOCATION": "yard_location", "AVAILABLE": "available", "QUANTITY": "quantity", "BIN LOCATION": "bin_location", "INTERCHANGE": "interchange", "WARRANTY TYPE": "warranty_type", "WARRANTY LENGTH": "warranty_length", "WARRANTY INFO": "warranty_info", "Image URL": "image_url", } _CREATE_PARTS = """ CREATE TABLE IF NOT EXISTS parts ( id INTEGER PRIMARY KEY, stock_number TEXT, description TEXT, customer_price TEXT, list_price TEXT, part_type TEXT, grade TEXT, yard_location TEXT, available TEXT, quantity TEXT, bin_location TEXT, interchange TEXT, warranty_type TEXT, warranty_length TEXT, warranty_info TEXT, image_url TEXT ) """ _CREATE_META = """ CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT ) """ class PartsRepo: """CSV-backed parts repository with fuzzy search.""" def __init__(self, csv_path: str = "", sqlite_path: str = "") -> None: self._csv_path = csv_path self._sqlite_path = sqlite_path or ":memory:" self._conn: sqlite3.Connection | None = None self._lock = threading.Lock() self._rows: list[dict] = [] self._search_keys: list[str] = [] self._init_schema() self.reload_if_changed() # ── Public API ──────────────────────────────────────────────────────────── def count(self) -> int: return len(self._rows) def available_makes(self) -> list[str]: """Return the alphabetical list of canonical makes present in the loaded parts data.""" seen: set[str] = set() for row in self._rows: for make in _detect_makes(row.get("description", "")): seen.add(make) return sorted(seen) def available_models(self) -> list[str]: """Return the alphabetical list of canonical models present in the loaded parts data.""" seen: set[str] = set() for row in self._rows: for model in _detect_models(row.get("description", "")): seen.add(model) return sorted(seen) def get(self, sku: str) -> dict | None: """Return the single part matching `sku`, or None.""" for row in self._rows: if row.get("stock_number", "").strip() == sku.strip(): return row conn = self._conn if conn is None: return None r = conn.execute( "SELECT * FROM parts WHERE stock_number = ? LIMIT 1", (sku,) ).fetchone() return dict(r) if r else None def search( self, query: str, *, makes: Iterable[str] = (), models: Iterable[str] = (), sides: Iterable[str] = (), limit: int = 20, ) -> list[dict]: """Fuzzy search across stock_number, description, and interchange. Optional `makes`, `models`, `sides` narrow the result set before scoring so the caller can implement filter chips. """ query = query.strip() active_makes = set(makes) active_models = set(models) active_sides = set(sides) pool = self._rows if active_makes: pool = [r for r in pool if active_makes & set(_detect_makes(r.get("description", "")))] if active_models: pool = [r for r in pool if active_models & set(_detect_models(r.get("description", "")))] if active_sides: pool = [r for r in pool if _detect_side(r.get("stock_number", "")) in active_sides] if not query: return pool[:limit] if _HAS_RAPIDFUZZ: keys = [ f"{r.get('stock_number','')} {r.get('description','')} {r.get('interchange','')}".lower() for r in pool ] hits = process.extract( query.lower(), keys, scorer=fuzz.WRatio, limit=limit, score_cutoff=30, ) return [pool[idx] for _, _, idx in hits] # Fallback: simple LIKE filter when rapidfuzz is absent q = query.lower() results = [ r for r in pool if q in (r.get("stock_number") or "").lower() or q in (r.get("description") or "").lower() or q in (r.get("interchange") or "").lower() ] return results[:limit] def reload_if_changed(self) -> bool: """Reload the CSV into SQLite if its mtime is newer than the cached one. Returns True if a reload happened. """ import os if not self._csv_path or not os.path.exists(self._csv_path): if not self._rows: self._load_from_db() return False csv_mtime = str(os.path.getmtime(self._csv_path)) cached = self._get_meta("csv_mtime") if cached == csv_mtime and self._rows: return False self._reload() self._set_meta("csv_mtime", csv_mtime) return True def close(self) -> None: if self._conn: self._conn.close() self._conn = None # ── Internal ────────────────────────────────────────────────────────────── def _init_schema(self) -> None: try: self._conn = sqlite3.connect( self._sqlite_path, check_same_thread=False ) self._conn.row_factory = sqlite3.Row self._conn.execute("PRAGMA journal_mode=WAL") self._conn.execute(_CREATE_PARTS) self._conn.execute(_CREATE_META) self._conn.commit() except Exception as exc: log.exception("Could not open SQLite database: %s", exc) self._conn = None def _get_meta(self, key: str) -> str: conn = self._conn if conn is None: return "" try: row = conn.execute( "SELECT value FROM meta WHERE key = ?", (key,) ).fetchone() return row[0] if row else "" except Exception: return "" def _set_meta(self, key: str, value: str) -> None: conn = self._conn if conn is None: return conn.execute( "INSERT INTO meta (key, value) VALUES (?, ?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value), ) conn.commit() def _reload(self) -> None: conn = self._conn if conn is None: return import sys try: csv.field_size_limit(sys.maxsize) except OverflowError: csv.field_size_limit(2 ** 31 - 1) conn.execute("DELETE FROM parts") conn.commit() cols_db = list(_CSV_TO_COL.values()) placeholders = ", ".join("?" * len(cols_db)) insert_sql = ( f"INSERT INTO parts ({', '.join(cols_db)}) VALUES ({placeholders})" ) rows_loaded: list[dict] = [] try: with open(self._csv_path, newline="", encoding="utf-8-sig", errors="replace") as f: reader = csv.DictReader(f) batch: list[tuple] = [] for csv_row in reader: values = tuple( str(csv_row.get(csv_col, "") or "").strip() for csv_col in _CSV_TO_COL ) batch.append(values) rows_loaded.append(dict(zip(cols_db, values))) if len(batch) >= 500: conn.executemany(insert_sql, batch) batch.clear() if batch: conn.executemany(insert_sql, batch) conn.commit() except Exception as exc: log.exception("CSV import failed: %s", exc) log.info("Loaded %d parts from %s", len(rows_loaded), self._csv_path) self._rows = rows_loaded def _load_from_db(self) -> None: conn = self._conn if conn is None: return try: rows = conn.execute("SELECT * FROM parts").fetchall() self._rows = [dict(r) for r in rows] except Exception: self._rows = [] def _row_count(self) -> int: conn = self._conn if conn is None: return 0 try: return conn.execute("SELECT COUNT(*) FROM parts").fetchone()[0] except Exception: return 0 def _rebuild_search_index(self) -> None: self._search_keys = [ f"{r.get('stock_number','')} {r.get('description','')} {r.get('interchange','')}".lower() for r in self._rows ] # Backwards-compat alias so any code that uses DataLoader still works. DataLoader = PartsRepo