fix(jlcpcb): use platform user data dir for parts database (#167)

JLCPCBPartsManager defaulted db_path to a "data/" directory computed
relative to __file__, which fails with read-only filesystems when the
package is installed to a system-managed prefix (e.g. /nix/store, an
immutable container image, or /usr/lib). The same pattern in
download_jlcpcb.py would silently scatter the ~1.5 GB JLCPCB cache
inside the install tree even when it is writable.

The original integration plan (docs/archive/JLCPCB_INTEGRATION_PLAN.md)
called for a per-user database under ~/.kicad-mcp/. This change moves
the default to the platform-appropriate user data directory by adding
a new PlatformHelper.get_data_dir() helper that mirrors the existing
get_config_dir() / get_cache_dir() conventions:

  - Linux:   XDG_DATA_HOME/kicad-mcp or ~/.local/share/kicad-mcp
  - macOS:   ~/Library/Application Support/kicad-mcp
  - Windows: %USERPROFILE%\.kicad-mcp\data

Both JLCPCBPartsManager and download_jlcpcb.py now resolve their
database paths through this helper. ensure_directories() and
detect_platform() include the new directory. Unit tests parallel to
the existing config_dir/cache_dir cases cover platform-appropriate
paths and the relative-XDG_DATA_HOME edge case.
This commit is contained in:
Sean Link
2026-05-18 11:40:13 -07:00
committed by GitHub
parent 5c6b55453e
commit 4e845f24ce
4 changed files with 86 additions and 8 deletions

View File

@@ -19,11 +19,14 @@ import sys
import time import time
from pathlib import Path from pathlib import Path
DATA_DIR = Path(__file__).parent / "data" sys.path.insert(0, str(Path(__file__).parent / "python"))
DATA_DIR.mkdir(exist_ok=True) from utils.platform_helper import PlatformHelper # noqa: E402
DATA_DIR = PlatformHelper.get_data_dir()
DATA_DIR.mkdir(parents=True, exist_ok=True)
CACHE_DIR = DATA_DIR / "jlcparts_cache" CACHE_DIR = DATA_DIR / "jlcparts_cache"
CACHE_DIR.mkdir(exist_ok=True) CACHE_DIR.mkdir(parents=True, exist_ok=True)
BASE_URL = "https://yaqwsx.github.io/jlcparts/data" BASE_URL = "https://yaqwsx.github.io/jlcparts/data"
MAX_PARTS = 30 # probe up to this many split volumes MAX_PARTS = 30 # probe up to this many split volumes

View File

@@ -13,6 +13,8 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
from utils.platform_helper import PlatformHelper
logger = logging.getLogger("kicad_interface") logger = logging.getLogger("kicad_interface")
@@ -28,13 +30,13 @@ class JLCPCBPartsManager:
Initialize parts database manager Initialize parts database manager
Args: Args:
db_path: Path to SQLite database file (default: data/jlcpcb_parts.db) db_path: Path to SQLite database file (default: platform-specific
user data directory, e.g. ~/.local/share/kicad-mcp/jlcpcb_parts.db
on Linux). See PlatformHelper.get_data_dir() for platform paths.
""" """
if db_path is None: if db_path is None:
# Default to data directory in project root data_dir = PlatformHelper.get_data_dir()
project_root = Path(__file__).parent.parent.parent data_dir.mkdir(parents=True, exist_ok=True)
data_dir = project_root / "data"
data_dir.mkdir(exist_ok=True)
db_path = str(data_dir / "jlcpcb_parts.db") db_path = str(data_dir / "jlcpcb_parts.db")
self.db_path = db_path self.db_path = db_path

View File

@@ -261,6 +261,37 @@ class PlatformHelper:
else: else:
return PlatformHelper.get_config_dir() / "cache" return PlatformHelper.get_config_dir() / "cache"
@staticmethod
def get_data_dir() -> Path:
r"""
Get appropriate data directory for current platform
Used for application state that should persist across runs and is not
a transient cache (e.g. the JLCPCB parts database).
Follows platform conventions:
- Windows: %USERPROFILE%\.kicad-mcp\data
- Linux: $XDG_DATA_HOME/kicad-mcp or ~/.local/share/kicad-mcp
- macOS: ~/Library/Application Support/kicad-mcp
Returns:
Path to data directory
"""
if PlatformHelper.is_windows():
return PlatformHelper.get_config_dir() / "data"
elif PlatformHelper.is_linux():
xdg_data = os.environ.get("XDG_DATA_HOME")
if xdg_data:
xdg_data_path = Path(xdg_data).expanduser()
if xdg_data_path.is_absolute():
return xdg_data_path / "kicad-mcp"
logger.warning("Ignoring relative XDG_DATA_HOME: %s", xdg_data)
return Path.home() / ".local" / "share" / "kicad-mcp"
elif PlatformHelper.is_macos():
return Path.home() / "Library" / "Application Support" / "kicad-mcp"
else:
return PlatformHelper.get_config_dir() / "data"
@staticmethod @staticmethod
def ensure_directories() -> None: def ensure_directories() -> None:
"""Create all necessary directories if they don't exist""" """Create all necessary directories if they don't exist"""
@@ -268,6 +299,7 @@ class PlatformHelper:
PlatformHelper.get_config_dir(), PlatformHelper.get_config_dir(),
PlatformHelper.get_log_dir(), PlatformHelper.get_log_dir(),
PlatformHelper.get_cache_dir(), PlatformHelper.get_cache_dir(),
PlatformHelper.get_data_dir(),
] ]
for directory in dirs_to_create: for directory in dirs_to_create:
@@ -317,6 +349,7 @@ def detect_platform() -> dict:
"config_dir": str(PlatformHelper.get_config_dir()), "config_dir": str(PlatformHelper.get_config_dir()),
"log_dir": str(PlatformHelper.get_log_dir()), "log_dir": str(PlatformHelper.get_log_dir()),
"cache_dir": str(PlatformHelper.get_cache_dir()), "cache_dir": str(PlatformHelper.get_cache_dir()),
"data_dir": str(PlatformHelper.get_data_dir()),
"kicad_python_paths": [str(p) for p in PlatformHelper.get_kicad_python_paths()], "kicad_python_paths": [str(p) for p in PlatformHelper.get_kicad_python_paths()],
} }

View File

@@ -69,6 +69,45 @@ class TestPathGeneration:
assert cache_dir.exists(), f"Cache dir should exist: {cache_dir}" assert cache_dir.exists(), f"Cache dir should exist: {cache_dir}"
assert cache_dir.is_dir(), f"Cache dir should be a directory: {cache_dir}" assert cache_dir.is_dir(), f"Cache dir should be a directory: {cache_dir}"
def test_data_dir_exists_after_ensure(self):
"""Test that data directory is created"""
PlatformHelper.ensure_directories()
data_dir = PlatformHelper.get_data_dir()
assert data_dir.exists(), f"Data dir should exist: {data_dir}"
assert data_dir.is_dir(), f"Data dir should be a directory: {data_dir}"
def test_data_dir_is_platform_appropriate(self):
"""Test that data directory follows platform conventions"""
data_dir = PlatformHelper.get_data_dir()
if PlatformHelper.is_linux():
# Should be ~/.local/share/kicad-mcp or $XDG_DATA_HOME/kicad-mcp
xdg = os.environ.get("XDG_DATA_HOME")
if xdg and Path(xdg).is_absolute():
expected = Path(xdg) / "kicad-mcp"
else:
expected = Path.home() / ".local" / "share" / "kicad-mcp"
assert data_dir == expected
elif PlatformHelper.is_windows():
# Should be %USERPROFILE%\.kicad-mcp\data
expected = Path.home() / ".kicad-mcp" / "data"
assert data_dir == expected
elif PlatformHelper.is_macos():
# Should be ~/Library/Application Support/kicad-mcp
expected = Path.home() / "Library" / "Application Support" / "kicad-mcp"
assert data_dir == expected
def test_data_dir_ignores_relative_xdg_data_home(self, monkeypatch):
"""Relative XDG_DATA_HOME should be ignored on Linux."""
monkeypatch.setattr(PlatformHelper, "is_linux", staticmethod(lambda: True))
monkeypatch.setattr(PlatformHelper, "is_windows", staticmethod(lambda: False))
monkeypatch.setattr(PlatformHelper, "is_macos", staticmethod(lambda: False))
monkeypatch.setenv("XDG_DATA_HOME", "relative/data")
assert PlatformHelper.get_data_dir() == Path.home() / ".local" / "share" / "kicad-mcp"
def test_config_dir_is_platform_appropriate(self): def test_config_dir_is_platform_appropriate(self):
"""Test that config directory follows platform conventions""" """Test that config directory follows platform conventions"""
config_dir = PlatformHelper.get_config_dir() config_dir = PlatformHelper.get_config_dir()
@@ -146,6 +185,7 @@ class TestDetectPlatform:
"config_dir", "config_dir",
"log_dir", "log_dir",
"cache_dir", "cache_dir",
"data_dir",
"kicad_python_paths", "kicad_python_paths",
] ]
for key in required_keys: for key in required_keys: