mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 02:31:45 +01:00
feat: add core XDG path resolution module (#775)
## ℹ️ Description Core module for XDG Base Directory specification support. - Link to the related issue(s): N/A (new feature) - Adds portable and XDG installation mode path resolution ## 📋 Changes Summary - New `xdg_paths.py` module with 11 path resolution functions - Comprehensive test suite (32 tests, 95% coverage) - German translations for all user-facing strings - Moved `platformdirs` from dev to runtime dependencies **Part 1 of 3 for XDG support** - Depends on: None - Preserves: extend command, ContactDefaults.location ### ⚙️ Type of Change - [x] ✨ New feature (adds new functionality without breaking existing usage) ## ✅ Checklist - [x] I have reviewed my changes to ensure they meet the project's standards. - [x] I have tested my changes and ensured that all tests pass (`pdm run test`). - [x] I have formatted the code (`pdm run format`). - [x] I have verified that linting passes (`pdm run lint`). - [x] I have updated documentation where necessary. By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for portable and XDG-standard installation modes for flexible config, cache, and state storage. * **Chores** * Added a runtime dependency to handle platform-specific directory locations. * **Tests** * Added comprehensive unit tests covering path resolution, installation-mode detection, interactive prompts, and Unicode path handling. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -39,6 +39,7 @@ dependencies = [
|
||||
"colorama",
|
||||
"jaraco.text", # required by pkg_resources during runtime
|
||||
"nodriver==0.47.*", # Pin to 0.47 until upstream fixes UTF-8 decoding issues introduced in 0.48
|
||||
"platformdirs>=2.1.0",
|
||||
"pydantic>=2.11.0",
|
||||
"ruamel.yaml",
|
||||
"psutil",
|
||||
@@ -60,7 +61,6 @@ dev = [
|
||||
"autopep8",
|
||||
"yamlfix",
|
||||
"pyinstaller",
|
||||
"platformdirs",
|
||||
"types-requests>=2.32.0.20250515",
|
||||
"pytest-mock>=3.14.0",
|
||||
]
|
||||
|
||||
@@ -616,3 +616,21 @@ kleinanzeigen_bot/model/update_check_state.py:
|
||||
"Interval too short: %s. Minimum interval is 1d. Using default interval for this run.": "Intervall zu kurz: %s. Das Mindestintervall beträgt 1 Tag. Es wird das Standardintervall für diesen Durchlauf verwendet."
|
||||
"Invalid interval format or unsupported unit: %s. Using default interval for this run.": "Ungültiges Intervallformat oder nicht unterstützte Einheit: %s. Es wird das Standardintervall für diesen Durchlauf verwendet."
|
||||
"Negative interval: %s. Minimum interval is 1d. Using default interval for this run.": "Negatives Intervall: %s. Das Mindestintervall beträgt 1 Tag. Es wird das Standardintervall für diesen Durchlauf verwendet."
|
||||
|
||||
#################################################
|
||||
kleinanzeigen_bot/utils/xdg_paths.py:
|
||||
#################################################
|
||||
_ensure_directory:
|
||||
"Failed to create %s %s: %s": "Fehler beim Erstellen von %s %s: %s"
|
||||
detect_installation_mode:
|
||||
"Detected installation mode: %s": "Erkannter Installationsmodus: %s"
|
||||
"No existing installation found": "Keine bestehende Installation gefunden"
|
||||
prompt_installation_mode:
|
||||
"Non-interactive mode detected, defaulting to portable installation": "Nicht-interaktiver Modus erkannt, Standard-Installation: portabel"
|
||||
"Choose installation type:": "Installationstyp wählen:"
|
||||
"[1] Portable (current directory)": "[1] Portabel (aktuelles Verzeichnis)"
|
||||
"[2] System-wide (XDG directories)": "[2] Systemweit (XDG-Verzeichnisse)"
|
||||
"Enter 1 or 2: ": "1 oder 2 eingeben: "
|
||||
"Defaulting to portable installation mode": "Standard-Installationsmodus: portabel"
|
||||
"User selected installation mode: %s": "Benutzer hat Installationsmodus gewählt: %s"
|
||||
"Invalid choice. Please enter 1 or 2.": "Ungültige Auswahl. Bitte 1 oder 2 eingeben."
|
||||
|
||||
269
src/kleinanzeigen_bot/utils/xdg_paths.py
Normal file
269
src/kleinanzeigen_bot/utils/xdg_paths.py
Normal file
@@ -0,0 +1,269 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
|
||||
"""XDG Base Directory path resolution with backward compatibility.
|
||||
|
||||
Supports two installation modes:
|
||||
- Portable: All files in current working directory (for existing installations)
|
||||
- System-wide: Files organized in XDG directories (for new installations or package managers)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from gettext import gettext as _
|
||||
from pathlib import Path
|
||||
from typing import Final, Literal, cast
|
||||
|
||||
import platformdirs
|
||||
|
||||
from kleinanzeigen_bot.utils import loggers
|
||||
|
||||
LOG: Final[loggers.Logger] = loggers.get_logger(__name__)
|
||||
|
||||
APP_NAME: Final[str] = "kleinanzeigen-bot"
|
||||
|
||||
InstallationMode = Literal["portable", "xdg"]
|
||||
PathCategory = Literal["config", "cache", "state"]
|
||||
|
||||
|
||||
def _normalize_mode(mode: str | InstallationMode) -> InstallationMode:
|
||||
"""Validate and normalize installation mode input."""
|
||||
if mode in {"portable", "xdg"}:
|
||||
return cast(InstallationMode, mode)
|
||||
raise ValueError(f"Unsupported installation mode: {mode}")
|
||||
|
||||
|
||||
def _ensure_directory(path: Path, description: str) -> None:
|
||||
"""Create directory and verify it exists."""
|
||||
LOG.debug("Creating directory: %s", path)
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
LOG.error("Failed to create %s %s: %s", description, path, exc)
|
||||
raise
|
||||
if not path.is_dir():
|
||||
raise NotADirectoryError(str(path))
|
||||
|
||||
|
||||
def get_xdg_base_dir(category: PathCategory) -> Path:
|
||||
"""Get XDG base directory for the given category.
|
||||
|
||||
Args:
|
||||
category: The XDG category (config, cache, or state)
|
||||
|
||||
Returns:
|
||||
Path to the XDG base directory for this app
|
||||
"""
|
||||
resolved: str | None = None
|
||||
match category:
|
||||
case "config":
|
||||
resolved = platformdirs.user_config_dir(APP_NAME)
|
||||
case "cache":
|
||||
resolved = platformdirs.user_cache_dir(APP_NAME)
|
||||
case "state":
|
||||
resolved = platformdirs.user_state_dir(APP_NAME)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported XDG category: {category}")
|
||||
|
||||
if resolved is None:
|
||||
raise RuntimeError(f"Failed to resolve XDG base directory for category: {category}")
|
||||
|
||||
base_dir = Path(resolved)
|
||||
|
||||
LOG.debug("XDG %s directory: %s", category, base_dir)
|
||||
return base_dir
|
||||
|
||||
|
||||
def detect_installation_mode() -> InstallationMode | None:
|
||||
"""Detect installation mode based on config file location.
|
||||
|
||||
Returns:
|
||||
"portable" if ./config.yaml exists in CWD
|
||||
"xdg" if config exists in XDG location
|
||||
None if neither exists (first run)
|
||||
"""
|
||||
# Check for portable installation (./config.yaml in CWD)
|
||||
portable_config = Path.cwd() / "config.yaml"
|
||||
LOG.debug("Checking for portable config at: %s", portable_config)
|
||||
|
||||
if portable_config.exists():
|
||||
LOG.info("Detected installation mode: %s", "portable")
|
||||
return "portable"
|
||||
|
||||
# Check for XDG installation
|
||||
xdg_config = get_xdg_base_dir("config") / "config.yaml"
|
||||
LOG.debug("Checking for XDG config at: %s", xdg_config)
|
||||
|
||||
if xdg_config.exists():
|
||||
LOG.info("Detected installation mode: %s", "xdg")
|
||||
return "xdg"
|
||||
|
||||
# Neither exists - first run
|
||||
LOG.info("No existing installation found")
|
||||
return None
|
||||
|
||||
|
||||
def prompt_installation_mode() -> InstallationMode:
|
||||
"""Prompt user to choose installation mode on first run.
|
||||
|
||||
Returns:
|
||||
"portable" or "xdg" based on user choice, or "portable" as default for non-interactive mode
|
||||
"""
|
||||
# Check if running in non-interactive mode (no stdin or not a TTY)
|
||||
if not sys.stdin or not sys.stdin.isatty():
|
||||
LOG.info("Non-interactive mode detected, defaulting to portable installation")
|
||||
return "portable"
|
||||
|
||||
print(_("Choose installation type:"))
|
||||
print(_("[1] Portable (current directory)"))
|
||||
print(_("[2] System-wide (XDG directories)"))
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input(_("Enter 1 or 2: ")).strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
# Non-interactive or interrupted - default to portable
|
||||
print() # newline after ^C or EOF
|
||||
LOG.info("Defaulting to portable installation mode")
|
||||
return "portable"
|
||||
|
||||
if choice == "1":
|
||||
mode: InstallationMode = "portable"
|
||||
LOG.info("User selected installation mode: %s", mode)
|
||||
return mode
|
||||
if choice == "2":
|
||||
mode = "xdg"
|
||||
LOG.info("User selected installation mode: %s", mode)
|
||||
return mode
|
||||
print(_("Invalid choice. Please enter 1 or 2."))
|
||||
|
||||
|
||||
def get_config_file_path(mode: str | InstallationMode) -> Path:
|
||||
"""Get config.yaml file path for the given mode.
|
||||
|
||||
Args:
|
||||
mode: Installation mode (portable or xdg)
|
||||
|
||||
Returns:
|
||||
Path to config.yaml
|
||||
"""
|
||||
mode = _normalize_mode(mode)
|
||||
config_path = Path.cwd() / "config.yaml" if mode == "portable" else get_xdg_base_dir("config") / "config.yaml"
|
||||
|
||||
LOG.debug("Resolving config file path for mode '%s': %s", mode, config_path)
|
||||
return config_path
|
||||
|
||||
|
||||
def get_ad_files_search_dir(mode: str | InstallationMode) -> Path:
|
||||
"""Get directory to search for ad files.
|
||||
|
||||
Ad files are searched relative to the config file directory,
|
||||
matching the documented behavior that glob patterns are relative to config.yaml.
|
||||
|
||||
Args:
|
||||
mode: Installation mode (portable or xdg)
|
||||
|
||||
Returns:
|
||||
Path to ad files search directory (same as config file directory)
|
||||
"""
|
||||
mode = _normalize_mode(mode)
|
||||
search_dir = Path.cwd() if mode == "portable" else get_xdg_base_dir("config")
|
||||
|
||||
LOG.debug("Resolving ad files search directory for mode '%s': %s", mode, search_dir)
|
||||
return search_dir
|
||||
|
||||
|
||||
def get_downloaded_ads_path(mode: str | InstallationMode) -> Path:
|
||||
"""Get downloaded ads directory path.
|
||||
|
||||
Args:
|
||||
mode: Installation mode (portable or xdg)
|
||||
|
||||
Returns:
|
||||
Path to downloaded ads directory
|
||||
|
||||
Note:
|
||||
Creates the directory if it doesn't exist.
|
||||
"""
|
||||
mode = _normalize_mode(mode)
|
||||
ads_path = Path.cwd() / "downloaded-ads" if mode == "portable" else get_xdg_base_dir("config") / "downloaded-ads"
|
||||
|
||||
LOG.debug("Resolving downloaded ads path for mode '%s': %s", mode, ads_path)
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
_ensure_directory(ads_path, "downloaded ads directory")
|
||||
|
||||
return ads_path
|
||||
|
||||
|
||||
def get_browser_profile_path(mode: str | InstallationMode, config_override: str | None = None) -> Path:
|
||||
"""Get browser profile directory path.
|
||||
|
||||
Args:
|
||||
mode: Installation mode (portable or xdg)
|
||||
config_override: Optional config override path (takes precedence)
|
||||
|
||||
Returns:
|
||||
Path to browser profile directory
|
||||
|
||||
Note:
|
||||
Creates the directory if it doesn't exist.
|
||||
"""
|
||||
mode = _normalize_mode(mode)
|
||||
if config_override:
|
||||
profile_path = Path(config_override)
|
||||
LOG.debug("Resolving browser profile path for mode '%s' (config override): %s", mode, profile_path)
|
||||
elif mode == "portable":
|
||||
profile_path = Path.cwd() / ".temp" / "browser-profile"
|
||||
LOG.debug("Resolving browser profile path for mode '%s': %s", mode, profile_path)
|
||||
else: # xdg
|
||||
profile_path = get_xdg_base_dir("cache") / "browser-profile"
|
||||
LOG.debug("Resolving browser profile path for mode '%s': %s", mode, profile_path)
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
_ensure_directory(profile_path, "browser profile directory")
|
||||
|
||||
return profile_path
|
||||
|
||||
|
||||
def get_log_file_path(basename: str, mode: str | InstallationMode) -> Path:
|
||||
"""Get log file path.
|
||||
|
||||
Args:
|
||||
basename: Log file basename (without .log extension)
|
||||
mode: Installation mode (portable or xdg)
|
||||
|
||||
Returns:
|
||||
Path to log file
|
||||
"""
|
||||
mode = _normalize_mode(mode)
|
||||
log_path = Path.cwd() / f"{basename}.log" if mode == "portable" else get_xdg_base_dir("state") / f"{basename}.log"
|
||||
|
||||
LOG.debug("Resolving log file path for mode '%s': %s", mode, log_path)
|
||||
|
||||
# Create parent directory if it doesn't exist
|
||||
_ensure_directory(log_path.parent, "log directory")
|
||||
|
||||
return log_path
|
||||
|
||||
|
||||
def get_update_check_state_path(mode: str | InstallationMode) -> Path:
|
||||
"""Get update check state file path.
|
||||
|
||||
Args:
|
||||
mode: Installation mode (portable or xdg)
|
||||
|
||||
Returns:
|
||||
Path to update check state file
|
||||
"""
|
||||
mode = _normalize_mode(mode)
|
||||
state_path = Path.cwd() / ".temp" / "update_check_state.json" if mode == "portable" else get_xdg_base_dir("state") / "update_check_state.json"
|
||||
|
||||
LOG.debug("Resolving update check state path for mode '%s': %s", mode, state_path)
|
||||
|
||||
# Create parent directory if it doesn't exist
|
||||
_ensure_directory(state_path.parent, "update check state directory")
|
||||
|
||||
return state_path
|
||||
485
tests/unit/test_xdg_paths.py
Normal file
485
tests/unit/test_xdg_paths.py
Normal file
@@ -0,0 +1,485 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
|
||||
"""Unit tests for XDG paths module."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kleinanzeigen_bot.utils import xdg_paths
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class TestGetXdgBaseDir:
|
||||
"""Tests for get_xdg_base_dir function."""
|
||||
|
||||
def test_returns_state_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test resolving XDG state directory."""
|
||||
state_dir = tmp_path / "state"
|
||||
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(state_dir / app_name))
|
||||
|
||||
resolved = xdg_paths.get_xdg_base_dir("state")
|
||||
|
||||
assert resolved == state_dir / "kleinanzeigen-bot"
|
||||
|
||||
def test_raises_for_unknown_category(self) -> None:
|
||||
"""Test invalid category handling."""
|
||||
with pytest.raises(ValueError, match="Unsupported XDG category"):
|
||||
xdg_paths.get_xdg_base_dir("invalid") # type: ignore[arg-type]
|
||||
|
||||
def test_raises_when_base_dir_is_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test runtime error when platformdirs returns None."""
|
||||
monkeypatch.setattr("platformdirs.user_state_dir", lambda _app_name, *args, **kwargs: None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Failed to resolve XDG base directory"):
|
||||
xdg_paths.get_xdg_base_dir("state")
|
||||
|
||||
|
||||
class TestDetectInstallationMode:
|
||||
"""Tests for detect_installation_mode function."""
|
||||
|
||||
def test_detects_portable_mode_when_config_exists_in_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode is detected when config.yaml exists in CWD."""
|
||||
# Setup: Create config.yaml in CWD
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "config.yaml").touch()
|
||||
|
||||
# Execute
|
||||
mode = xdg_paths.detect_installation_mode()
|
||||
|
||||
# Verify
|
||||
assert mode == "portable"
|
||||
|
||||
def test_detects_xdg_mode_when_config_exists_in_xdg_location(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode is detected when config exists in XDG location."""
|
||||
# Setup: Create config in mock XDG directory
|
||||
xdg_config = tmp_path / "config" / "kleinanzeigen-bot"
|
||||
xdg_config.mkdir(parents=True)
|
||||
(xdg_config / "config.yaml").touch()
|
||||
|
||||
# Mock platformdirs to return our test directory
|
||||
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "config" / app_name))
|
||||
|
||||
# Change to a different directory (no local config)
|
||||
cwd = tmp_path / "cwd"
|
||||
cwd.mkdir()
|
||||
monkeypatch.chdir(cwd)
|
||||
|
||||
# Execute
|
||||
mode = xdg_paths.detect_installation_mode()
|
||||
|
||||
# Verify
|
||||
assert mode == "xdg"
|
||||
|
||||
def test_returns_none_when_no_config_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that None is returned when no config exists anywhere."""
|
||||
# Setup: Empty directories
|
||||
monkeypatch.chdir(tmp_path)
|
||||
# Mock XDG to return a non-existent path
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
lambda app_name, *args, **kwargs: str(tmp_path / "nonexistent-xdg" / app_name),
|
||||
)
|
||||
|
||||
# Execute
|
||||
mode = xdg_paths.detect_installation_mode()
|
||||
|
||||
# Verify
|
||||
assert mode is None
|
||||
|
||||
|
||||
class TestGetConfigFilePath:
|
||||
"""Tests for get_config_file_path function."""
|
||||
|
||||
def test_returns_cwd_path_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode returns ./config.yaml."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
path = xdg_paths.get_config_file_path("portable")
|
||||
|
||||
assert path == tmp_path / "config.yaml"
|
||||
|
||||
def test_returns_xdg_path_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode returns XDG config path."""
|
||||
xdg_config = tmp_path / "config"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_config / app_name),
|
||||
)
|
||||
|
||||
path = xdg_paths.get_config_file_path("xdg")
|
||||
|
||||
assert "kleinanzeigen-bot" in str(path)
|
||||
assert path.name == "config.yaml"
|
||||
|
||||
|
||||
class TestGetAdFilesSearchDir:
|
||||
"""Tests for get_ad_files_search_dir function."""
|
||||
|
||||
def test_returns_cwd_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode searches in CWD."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
search_dir = xdg_paths.get_ad_files_search_dir("portable")
|
||||
|
||||
assert search_dir == tmp_path
|
||||
|
||||
def test_returns_xdg_config_dir_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode searches in XDG config directory (same as config file)."""
|
||||
xdg_config = tmp_path / "config"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_config / app_name),
|
||||
)
|
||||
|
||||
search_dir = xdg_paths.get_ad_files_search_dir("xdg")
|
||||
|
||||
assert "kleinanzeigen-bot" in str(search_dir)
|
||||
# Ad files searched in same directory as config file, not separate ads/ subdirectory
|
||||
assert search_dir.name == "kleinanzeigen-bot"
|
||||
|
||||
|
||||
class TestGetDownloadedAdsPath:
|
||||
"""Tests for get_downloaded_ads_path function."""
|
||||
|
||||
def test_returns_cwd_downloaded_ads_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode uses ./downloaded-ads/."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
ads_path = xdg_paths.get_downloaded_ads_path("portable")
|
||||
|
||||
assert ads_path == tmp_path / "downloaded-ads"
|
||||
|
||||
def test_creates_directory_if_not_exists(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that directory is created if it doesn't exist."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
ads_path = xdg_paths.get_downloaded_ads_path("portable")
|
||||
|
||||
assert ads_path.exists()
|
||||
assert ads_path.is_dir()
|
||||
|
||||
def test_returns_xdg_downloaded_ads_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode uses XDG config/downloaded-ads/."""
|
||||
xdg_config = tmp_path / "config"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_config / app_name),
|
||||
)
|
||||
|
||||
ads_path = xdg_paths.get_downloaded_ads_path("xdg")
|
||||
|
||||
assert "kleinanzeigen-bot" in str(ads_path)
|
||||
assert ads_path.name == "downloaded-ads"
|
||||
|
||||
|
||||
class TestGetBrowserProfilePath:
|
||||
"""Tests for get_browser_profile_path function."""
|
||||
|
||||
def test_returns_cwd_temp_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode uses ./.temp/browser-profile."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
profile_path = xdg_paths.get_browser_profile_path("portable")
|
||||
|
||||
assert profile_path == tmp_path / ".temp" / "browser-profile"
|
||||
|
||||
def test_returns_xdg_cache_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode uses XDG cache directory."""
|
||||
xdg_cache = tmp_path / "cache"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_cache_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_cache / app_name),
|
||||
)
|
||||
|
||||
profile_path = xdg_paths.get_browser_profile_path("xdg")
|
||||
|
||||
assert "kleinanzeigen-bot" in str(profile_path)
|
||||
assert profile_path.name == "browser-profile"
|
||||
|
||||
def test_creates_directory_if_not_exists(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that browser profile directory is created."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
profile_path = xdg_paths.get_browser_profile_path("portable")
|
||||
|
||||
assert profile_path.exists()
|
||||
assert profile_path.is_dir()
|
||||
|
||||
|
||||
class TestGetLogFilePath:
|
||||
"""Tests for get_log_file_path function."""
|
||||
|
||||
def test_returns_cwd_log_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode uses ./{basename}.log."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
log_path = xdg_paths.get_log_file_path("test", "portable")
|
||||
|
||||
assert log_path == tmp_path / "test.log"
|
||||
|
||||
def test_returns_xdg_state_log_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode uses XDG state directory."""
|
||||
xdg_state = tmp_path / "state"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_state_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_state / app_name),
|
||||
)
|
||||
|
||||
log_path = xdg_paths.get_log_file_path("test", "xdg")
|
||||
|
||||
assert "kleinanzeigen-bot" in str(log_path)
|
||||
assert log_path.name == "test.log"
|
||||
|
||||
|
||||
class TestGetUpdateCheckStatePath:
|
||||
"""Tests for get_update_check_state_path function."""
|
||||
|
||||
def test_returns_cwd_temp_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode uses ./.temp/update_check_state.json."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
state_path = xdg_paths.get_update_check_state_path("portable")
|
||||
|
||||
assert state_path == tmp_path / ".temp" / "update_check_state.json"
|
||||
|
||||
def test_returns_xdg_state_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode uses XDG state directory."""
|
||||
xdg_state = tmp_path / "state"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_state_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_state / app_name),
|
||||
)
|
||||
|
||||
state_path = xdg_paths.get_update_check_state_path("xdg")
|
||||
|
||||
assert "kleinanzeigen-bot" in str(state_path)
|
||||
assert state_path.name == "update_check_state.json"
|
||||
|
||||
|
||||
class TestPromptInstallationMode:
|
||||
"""Tests for prompt_installation_mode function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _force_identity_translation(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensure prompt strings are stable regardless of locale."""
|
||||
monkeypatch.setattr(xdg_paths, "_", lambda message: message)
|
||||
|
||||
def test_returns_portable_for_non_interactive_mode_no_stdin(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that non-interactive mode (no stdin) defaults to portable."""
|
||||
# Mock sys.stdin to be None (simulates non-interactive environment)
|
||||
monkeypatch.setattr("sys.stdin", None)
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
|
||||
assert mode == "portable"
|
||||
|
||||
def test_returns_portable_for_non_interactive_mode_not_tty(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that non-interactive mode (not a TTY) defaults to portable."""
|
||||
# Mock sys.stdin.isatty() to return False (simulates piped input or file redirect)
|
||||
mock_stdin = io.StringIO()
|
||||
mock_stdin.isatty = lambda: False # type: ignore[method-assign]
|
||||
monkeypatch.setattr("sys.stdin", mock_stdin)
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
|
||||
assert mode == "portable"
|
||||
|
||||
def test_returns_portable_when_user_enters_1(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test that user entering '1' selects portable mode."""
|
||||
# Mock sys.stdin to simulate interactive terminal
|
||||
mock_stdin = io.StringIO()
|
||||
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
|
||||
monkeypatch.setattr("sys.stdin", mock_stdin)
|
||||
|
||||
# Mock interactive input
|
||||
monkeypatch.setattr("builtins.input", lambda _: "1")
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
|
||||
assert mode == "portable"
|
||||
# Verify prompt was shown
|
||||
captured = capsys.readouterr()
|
||||
assert "Choose installation type:" in captured.out
|
||||
assert "[1] Portable" in captured.out
|
||||
|
||||
def test_returns_xdg_when_user_enters_2(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test that user entering '2' selects XDG mode."""
|
||||
# Mock sys.stdin to simulate interactive terminal
|
||||
mock_stdin = io.StringIO()
|
||||
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
|
||||
monkeypatch.setattr("sys.stdin", mock_stdin)
|
||||
|
||||
# Mock interactive input
|
||||
monkeypatch.setattr("builtins.input", lambda _: "2")
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
|
||||
assert mode == "xdg"
|
||||
# Verify prompt was shown
|
||||
captured = capsys.readouterr()
|
||||
assert "Choose installation type:" in captured.out
|
||||
assert "[2] System-wide" in captured.out
|
||||
|
||||
def test_reprompts_on_invalid_input_then_accepts_valid(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test that invalid input causes re-prompt, then valid input is accepted."""
|
||||
# Mock sys.stdin to simulate interactive terminal
|
||||
mock_stdin = io.StringIO()
|
||||
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
|
||||
monkeypatch.setattr("sys.stdin", mock_stdin)
|
||||
|
||||
# Mock sequence of inputs: invalid, then valid
|
||||
inputs = iter(["3", "invalid", "1"])
|
||||
monkeypatch.setattr("builtins.input", lambda _: next(inputs))
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
|
||||
assert mode == "portable"
|
||||
# Verify error message was shown
|
||||
captured = capsys.readouterr()
|
||||
assert "Invalid choice" in captured.out
|
||||
|
||||
def test_returns_portable_on_eof_error(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test that EOFError (Ctrl+D) defaults to portable mode."""
|
||||
# Mock sys.stdin to simulate interactive terminal
|
||||
mock_stdin = io.StringIO()
|
||||
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
|
||||
monkeypatch.setattr("sys.stdin", mock_stdin)
|
||||
|
||||
# Mock input raising EOFError
|
||||
def mock_input(_: str) -> str:
|
||||
raise EOFError
|
||||
|
||||
monkeypatch.setattr("builtins.input", mock_input)
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
|
||||
assert mode == "portable"
|
||||
# Verify newline was printed after EOF
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out.endswith("\n")
|
||||
|
||||
def test_returns_portable_on_keyboard_interrupt(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test that KeyboardInterrupt (Ctrl+C) defaults to portable mode."""
|
||||
# Mock sys.stdin to simulate interactive terminal
|
||||
mock_stdin = io.StringIO()
|
||||
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
|
||||
monkeypatch.setattr("sys.stdin", mock_stdin)
|
||||
|
||||
# Mock input raising KeyboardInterrupt
|
||||
def mock_input(_: str) -> str:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr("builtins.input", mock_input)
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
|
||||
assert mode == "portable"
|
||||
# Verify newline was printed after interrupt
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out.endswith("\n")
|
||||
|
||||
|
||||
class TestGetBrowserProfilePathWithOverride:
|
||||
"""Tests for get_browser_profile_path config_override parameter."""
|
||||
|
||||
def test_respects_config_override_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that config_override takes precedence in portable mode."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
custom_path = str(tmp_path / "custom" / "browser")
|
||||
profile_path = xdg_paths.get_browser_profile_path("portable", config_override=custom_path)
|
||||
|
||||
assert profile_path == Path(custom_path)
|
||||
assert profile_path.exists() # Verify directory was created
|
||||
assert profile_path.is_dir()
|
||||
|
||||
def test_respects_config_override_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that config_override takes precedence in XDG mode."""
|
||||
xdg_cache = tmp_path / "cache"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_cache_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_cache / app_name),
|
||||
)
|
||||
|
||||
custom_path = str(tmp_path / "custom" / "browser")
|
||||
profile_path = xdg_paths.get_browser_profile_path("xdg", config_override=custom_path)
|
||||
|
||||
assert profile_path == Path(custom_path)
|
||||
# Verify it didn't use XDG cache directory
|
||||
assert str(profile_path) != str(xdg_cache / "kleinanzeigen-bot" / "browser-profile")
|
||||
assert profile_path.exists()
|
||||
assert profile_path.is_dir()
|
||||
|
||||
|
||||
class TestUnicodeHandling:
|
||||
"""Tests for Unicode path handling (NFD vs NFC normalization)."""
|
||||
|
||||
def test_portable_mode_handles_unicode_in_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode works with Unicode characters in CWD path.
|
||||
|
||||
This tests the edge case where the current directory contains Unicode
|
||||
characters (e.g., user names with umlauts), which may be stored in
|
||||
different normalization forms (NFD on macOS, NFC on Linux/Windows).
|
||||
"""
|
||||
# Create directory with German umlaut in composed (NFC) form
|
||||
# ä = U+00E4 (NFC) vs a + ̈ = U+0061 + U+0308 (NFD)
|
||||
unicode_dir = tmp_path / "Müller_config"
|
||||
unicode_dir.mkdir()
|
||||
monkeypatch.chdir(unicode_dir)
|
||||
|
||||
# Get paths - should work regardless of normalization
|
||||
config_path = xdg_paths.get_config_file_path("portable")
|
||||
log_path = xdg_paths.get_log_file_path("test", "portable")
|
||||
|
||||
# Verify paths are within the Unicode directory
|
||||
assert config_path.parent == unicode_dir
|
||||
assert log_path.parent == unicode_dir
|
||||
assert config_path.name == "config.yaml"
|
||||
assert log_path.name == "test.log"
|
||||
|
||||
def test_xdg_mode_handles_unicode_in_paths(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode handles Unicode in XDG directory paths.
|
||||
|
||||
This tests the edge case where XDG directories contain Unicode
|
||||
characters (e.g., /Users/Müller/.config/), which may be in NFD
|
||||
form on macOS filesystems.
|
||||
"""
|
||||
# Create XDG directory with umlaut
|
||||
xdg_base = tmp_path / "Users" / "Müller" / ".config"
|
||||
xdg_base.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_base / app_name),
|
||||
)
|
||||
|
||||
# Get config path
|
||||
config_path = xdg_paths.get_config_file_path("xdg")
|
||||
|
||||
# Verify path contains the Unicode directory
|
||||
assert "Müller" in str(config_path) or "Mu\u0308ller" in str(config_path)
|
||||
assert config_path.name == "config.yaml"
|
||||
|
||||
def test_downloaded_ads_path_handles_unicode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that downloaded ads directory creation works with Unicode paths."""
|
||||
# Create XDG config directory with umlaut
|
||||
xdg_config = tmp_path / "config" / "Müller"
|
||||
xdg_config.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_config / app_name),
|
||||
)
|
||||
|
||||
# Get downloaded ads path - this will create the directory
|
||||
ads_path = xdg_paths.get_downloaded_ads_path("xdg")
|
||||
|
||||
# Verify directory was created successfully
|
||||
assert ads_path.exists()
|
||||
assert ads_path.is_dir()
|
||||
assert ads_path.name == "downloaded-ads"
|
||||
Reference in New Issue
Block a user