Files
2026-07-06 00:24:30 -05:00

252 lines
8.9 KiB
Python

"""Loads and validates config.json. Expands Windows env vars in paths.
Any user-data path left empty in config.json is filled in from
`app.paths` so all caches end up in the same `%LOCALAPPDATA%` folder.
"""
from __future__ import annotations
import glob
import json
import logging
import os
import re
import sys
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from . import paths as app_paths
log = logging.getLogger(__name__)
def _expand(path: str) -> str:
"""Expand %ENV% and ~ in a path string."""
if not path:
return path
return os.path.expandvars(os.path.expanduser(path))
# Matches dated parts filenames: MM-DD-YY or MM-DD-YYYY, whitespace, "OnPart",
# then any non-whitespace suffix (e.g. "_inv", "_inv(in)", "(in)"), then ".csv".
_DATED_CSV_RE = re.compile(
r"^(\d{2})-(\d{2})-(\d{2}|\d{4})\s+OnPart\S*\.csv$",
re.IGNORECASE,
)
def _parse_dated_filename(name: str) -> date | None:
"""Return the date encoded in a dated parts filename, or None.
Accepts any filename matching MM-DD-YY(YY) OnPart*.csv, e.g.
'OnPart.csv', 'OnPart_inv.csv', 'OnPart_inv(in).csv'.
"""
m = _DATED_CSV_RE.match(name)
if not m:
return None
mm_s, dd_s, yy_s = m.groups()
mm, dd = int(mm_s), int(dd_s)
# 2-digit years are interpreted as 20YY. 4-digit years used as-is.
year = int(yy_s) if len(yy_s) == 4 else 2000 + int(yy_s)
try:
return date(year, mm, dd)
except ValueError:
return None # e.g., "13-40-26"
def _find_dated_csv(directory: str) -> str:
"""Find the newest dated parts CSV file in `directory` whose date is
today or earlier. Returns the full path, or "" if none found."""
if not directory or not os.path.isdir(directory):
return ""
today = date.today()
best: tuple[date, str] | None = None
try:
entries = os.listdir(directory)
except OSError as e:
log.warning("Could not list %s: %s", directory, e)
return ""
for name in entries:
d = _parse_dated_filename(name)
if d is None or d > today:
continue
full = os.path.join(directory, name)
if not os.path.isfile(full):
continue
if best is None or d > best[0]:
best = (d, full)
return best[1] if best else ""
@dataclass
class Config:
csv_path: str = ""
csv_path_glob_fallback: str = ""
sqlite_path: str = ""
image_cache_dir: str = ""
smb_image_dir: str = ""
default_tab: str = "parts"
max_results: int = 15
ui_scale: float = 1.2
appearance_mode: str = "light"
color_theme: str = "blue"
# ShipEngine API credentials
shipengine_sandbox: bool = True
shipengine_api_key: str = ""
shipengine_return_key: str = ""
# USPS shipper (sender) address
usps_shipper_name: str = ""
usps_shipper_company: str = ""
usps_shipper_phone: str = ""
usps_shipper_address1: str = ""
usps_shipper_city: str = ""
usps_shipper_state: str = ""
usps_shipper_zip: str = ""
# FedEx Ship API credentials
fedex_sandbox: bool = False
fedex_account: str = ""
fedex_api_key: str = ""
fedex_secret: str = ""
# FedEx shipper (sender) address — fill in config.json for your location
fedex_shipper_name: str = ""
fedex_shipper_company: str = ""
fedex_shipper_phone: str = ""
fedex_shipper_address1: str = ""
fedex_shipper_city: str = ""
fedex_shipper_state: str = ""
fedex_shipper_zip: str = ""
# Resolved at runtime - not part of the JSON file itself.
resolved_csv_path: str = field(default="", init=False)
_loaded_path: Path | None = field(default=None, init=False)
# Per-machine override for the scan folder. Set by the UI from ui_state.json
# so each workstation can point at its own parts folder without editing
# the deployable config.json.
parts_folder_override: str = field(default="", init=False)
@classmethod
def load(cls, path: Path | None = None) -> "Config":
if path is None:
# Look next to the running app first; fall back to repo-root config.json.
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1]))
path = base / "config.json"
if not path.exists():
path = Path(__file__).resolve().parents[1] / "config.json"
with open(path, "r", encoding="utf-8") as f:
raw = json.load(f)
cfg = cls(**{k: raw[k] for k in raw if k in cls.__dataclass_fields__})
cfg._loaded_path = path
cfg.csv_path = _expand(cfg.csv_path)
cfg.csv_path_glob_fallback = _expand(cfg.csv_path_glob_fallback)
cfg.sqlite_path = _expand(cfg.sqlite_path)
cfg.image_cache_dir = _expand(cfg.image_cache_dir)
cfg.smb_image_dir = _expand(cfg.smb_image_dir)
# Fall back to the centralized appdata location whenever a cache path
# isn't explicitly set in config.json. This keeps every persisted
# artifact under one folder per user so it's easy to find / clear and
# never accidentally lands next to the .exe.
if not cfg.sqlite_path:
cfg.sqlite_path = app_paths.default_sqlite_path()
if not cfg.image_cache_dir:
cfg.image_cache_dir = app_paths.default_image_cache_dir()
# Make sure parent dirs for cache exist.
Path(cfg.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
Path(cfg.image_cache_dir).mkdir(parents=True, exist_ok=True)
# The parts_folder_override is set by the UI after load (from
# ui_state.json), so don't resolve the CSV here - the UI will call
# resolve_csv() after setting the override.
cfg.resolved_csv_path = cfg._resolve_csv()
return cfg
def _scan_directory(self) -> str:
"""Return the directory to scan for dated parts CSV files.
Resolution order:
1. parts_folder_override - the per-machine folder the user picked via
the Change folder button (stored in ui_state.json).
2. csv_path_glob_fallback's dirname - the config.json default.
3. csv_path's dirname - last-resort fallback.
"""
if self.parts_folder_override and os.path.isdir(self.parts_folder_override):
return self.parts_folder_override
if self.csv_path_glob_fallback:
d = os.path.dirname(self.csv_path_glob_fallback)
if d:
return d
if self.csv_path:
d = os.path.dirname(self.csv_path)
if d:
return d
return ""
def _resolve_csv(self) -> str:
"""Pick the CSV the app should load.
Resolution order:
1. Newest dated parts CSV file in the scan directory whose date is
today or earlier. This is the canonical source and always wins on
launch, even if the user previously picked a different file via
Change CSV path.
2. The last user-configured csv_path, if it exists on disk.
3. Newest match of csv_path_glob_fallback (legacy fallback).
"""
scan_dir = self._scan_directory()
dated = _find_dated_csv(scan_dir)
if dated:
log.info("CSV resolved via dated scan: %s (scan_dir=%s)", dated, scan_dir)
return dated
if self.csv_path and Path(self.csv_path).exists():
log.info(
"CSV resolved via csv_path fallback: %s (no dated files in %s)",
self.csv_path, scan_dir,
)
return self.csv_path
if self.csv_path_glob_fallback:
matches = glob.glob(self.csv_path_glob_fallback)
if matches:
matches.sort(key=lambda p: Path(p).stat().st_mtime, reverse=True)
log.info("CSV resolved via glob fallback: %s", matches[0])
return matches[0]
log.warning(
"CSV resolution found nothing - returning csv_path (may not exist): %s",
self.csv_path,
)
return self.csv_path # may not exist; caller decides what to do
def resolve_csv(self) -> str:
"""Re-run CSV resolution and update self.resolved_csv_path.
Call this before each load/reload so a newly-dropped dated file picks
up without restarting the app.
"""
self.resolved_csv_path = self._resolve_csv()
return self.resolved_csv_path
def save(self) -> None:
"""Write the current config back to config.json, preserving other fields."""
if not self._loaded_path:
return
try:
with open(self._loaded_path, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
data = {}
for k in self.__dataclass_fields__:
if k not in ("resolved_csv_path", "_loaded_path", "parts_folder_override"):
data[k] = getattr(self, k)
with open(self._loaded_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)