Initial commit
This commit is contained in:
+251
@@ -0,0 +1,251 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user