mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 10:31:50 +01:00
fix: add explicit workspace mode resolution for --config (#818)
This commit is contained in:
@@ -152,11 +152,6 @@ class WebScrapingMixin:
|
||||
self._default_timeout_config:TimeoutConfig | None = None
|
||||
self.config:BotConfig = cast(BotConfig, None)
|
||||
|
||||
@property
|
||||
def _installation_mode(self) -> str:
|
||||
"""Get installation mode with fallback to portable."""
|
||||
return getattr(self, "installation_mode_or_portable", "portable")
|
||||
|
||||
def _get_timeout_config(self) -> TimeoutConfig:
|
||||
config = getattr(self, "config", None)
|
||||
timeouts:TimeoutConfig | None = None
|
||||
@@ -225,7 +220,7 @@ class WebScrapingMixin:
|
||||
and not has_remote_debugging
|
||||
and not is_test_environment
|
||||
):
|
||||
self.browser_config.user_data_dir = str(xdg_paths.get_browser_profile_path(self._installation_mode))
|
||||
LOG.debug("No browser user_data_dir configured. Set browser.user_data_dir or --user-data-dir for non-test runs.")
|
||||
|
||||
# Chrome version detection and validation
|
||||
if has_remote_debugging:
|
||||
@@ -344,7 +339,7 @@ class WebScrapingMixin:
|
||||
user_data_dir_from_args,
|
||||
)
|
||||
if not effective_user_data_dir and not is_test_environment:
|
||||
effective_user_data_dir = str(xdg_paths.get_browser_profile_path(self._installation_mode))
|
||||
LOG.debug("No effective browser user_data_dir found. Browser will use its default profile location.")
|
||||
self.browser_config.user_data_dir = effective_user_data_dir
|
||||
|
||||
if not loggers.is_debug(LOG):
|
||||
@@ -365,6 +360,7 @@ class WebScrapingMixin:
|
||||
|
||||
# Enhanced profile directory handling
|
||||
if cfg.user_data_dir:
|
||||
xdg_paths.ensure_directory(Path(cfg.user_data_dir), "browser profile directory")
|
||||
profile_dir = os.path.join(cfg.user_data_dir, self.browser_config.profile_name or "Default")
|
||||
os.makedirs(profile_dir, exist_ok = True)
|
||||
prefs_file = os.path.join(profile_dir, "Preferences")
|
||||
|
||||
@@ -1,41 +1,61 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-FileCopyrightText: © Jens Bergmann 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)
|
||||
"""
|
||||
"""XDG Base Directory path resolution with workspace abstraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, replace
|
||||
from gettext import gettext as _
|
||||
from pathlib import Path
|
||||
from typing import Final, Literal, cast
|
||||
from typing import Final, Literal
|
||||
|
||||
import platformdirs
|
||||
|
||||
from kleinanzeigen_bot.utils import loggers
|
||||
from kleinanzeigen_bot.utils.files import abspath
|
||||
|
||||
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}")
|
||||
@dataclass(frozen = True)
|
||||
class Workspace:
|
||||
"""Resolved workspace paths for all bot side effects."""
|
||||
|
||||
mode:InstallationMode
|
||||
config_file:Path
|
||||
config_dir:Path # root directory for mode-dependent artifacts
|
||||
log_file:Path | None
|
||||
state_dir:Path
|
||||
download_dir:Path
|
||||
browser_profile_dir:Path
|
||||
diagnostics_dir:Path
|
||||
|
||||
@classmethod
|
||||
def for_config(cls, config_file:Path, log_basename:str) -> Workspace:
|
||||
"""Build a portable-style workspace rooted at the config parent directory."""
|
||||
config_file = config_file.resolve()
|
||||
config_dir = config_file.parent
|
||||
state_dir = config_dir / ".temp"
|
||||
return cls(
|
||||
mode = "portable",
|
||||
config_file = config_file,
|
||||
config_dir = config_dir,
|
||||
log_file = config_dir / f"{log_basename}.log",
|
||||
state_dir = state_dir,
|
||||
download_dir = config_dir / "downloaded-ads",
|
||||
browser_profile_dir = state_dir / "browser-profile",
|
||||
diagnostics_dir = state_dir / "diagnostics",
|
||||
)
|
||||
|
||||
|
||||
def _ensure_directory(path:Path, description:str) -> None:
|
||||
def ensure_directory(path:Path, description:str) -> None:
|
||||
"""Create directory and verify it exists."""
|
||||
LOG.debug("Creating directory: %s", path)
|
||||
try:
|
||||
@@ -47,15 +67,25 @@ def _ensure_directory(path:Path, description:str) -> None:
|
||||
raise NotADirectoryError(str(path))
|
||||
|
||||
|
||||
def _build_xdg_workspace(log_basename:str, config_file_override:Path | None = None) -> Workspace:
|
||||
"""Build an XDG-style workspace using standard user directories."""
|
||||
config_dir = get_xdg_base_dir("config").resolve()
|
||||
state_dir = get_xdg_base_dir("state").resolve()
|
||||
config_file = config_file_override.resolve() if config_file_override is not None else config_dir / "config.yaml"
|
||||
return Workspace(
|
||||
mode = "xdg",
|
||||
config_file = config_file,
|
||||
config_dir = config_dir,
|
||||
log_file = state_dir / f"{log_basename}.log",
|
||||
state_dir = state_dir,
|
||||
download_dir = config_dir / "downloaded-ads",
|
||||
browser_profile_dir = (get_xdg_base_dir("cache") / "browser-profile").resolve(),
|
||||
diagnostics_dir = (get_xdg_base_dir("cache") / "diagnostics").resolve(),
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
"""Get XDG base directory for the given category."""
|
||||
resolved:str | None = None
|
||||
match category:
|
||||
case "config":
|
||||
@@ -71,20 +101,12 @@ def get_xdg_base_dir(category:PathCategory) -> Path:
|
||||
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)
|
||||
def detect_installation_mode() -> Literal["portable", "xdg"] | None:
|
||||
"""Detect installation mode based on config file location."""
|
||||
portable_config = Path.cwd() / "config.yaml"
|
||||
LOG.debug("Checking for portable config at: %s", portable_config)
|
||||
|
||||
@@ -92,7 +114,6 @@ def detect_installation_mode() -> InstallationMode | None:
|
||||
LOG.debug("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)
|
||||
|
||||
@@ -100,37 +121,37 @@ def detect_installation_mode() -> InstallationMode | None:
|
||||
LOG.debug("Detected installation mode: %s", "xdg")
|
||||
return "xdg"
|
||||
|
||||
# Neither exists - first run
|
||||
LOG.info("No existing configuration (portable or system-wide) 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)
|
||||
def prompt_installation_mode() -> Literal["portable", "xdg"]:
|
||||
"""Prompt user to choose installation mode on first run."""
|
||||
if not sys.stdin or not sys.stdin.isatty():
|
||||
LOG.info("Non-interactive mode detected, defaulting to portable installation")
|
||||
return "portable"
|
||||
|
||||
portable_ws = Workspace.for_config((Path.cwd() / "config.yaml").resolve(), APP_NAME)
|
||||
xdg_workspace = _build_xdg_workspace(APP_NAME)
|
||||
|
||||
print(_("Choose installation type:"))
|
||||
print(_("[1] Portable (current directory)"))
|
||||
print(_("[2] System-wide (XDG directories)"))
|
||||
print(f" config: {portable_ws.config_file}")
|
||||
print(f" log: {portable_ws.log_file}")
|
||||
print(_("[2] User directories (per-user standard locations)"))
|
||||
print(f" config: {xdg_workspace.config_file}")
|
||||
print(f" log: {xdg_workspace.log_file}")
|
||||
|
||||
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
|
||||
print()
|
||||
LOG.info("Defaulting to portable installation mode")
|
||||
return "portable"
|
||||
|
||||
if choice == "1":
|
||||
mode:InstallationMode = "portable"
|
||||
mode:Literal["portable", "xdg"] = "portable"
|
||||
LOG.info("User selected installation mode: %s", mode)
|
||||
return mode
|
||||
if choice == "2":
|
||||
@@ -140,130 +161,122 @@ def prompt_installation_mode() -> InstallationMode:
|
||||
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
|
||||
def _detect_mode_from_footprints_with_hits(
|
||||
config_file:Path,
|
||||
) -> tuple[Literal["portable", "xdg", "ambiguous", "unknown"], list[Path], list[Path]]:
|
||||
"""
|
||||
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)
|
||||
Detect workspace mode and return concrete footprint hits for diagnostics.
|
||||
"""
|
||||
mode = _normalize_mode(mode)
|
||||
search_dir = Path.cwd() if mode == "portable" else get_xdg_base_dir("config")
|
||||
config_file = config_file.resolve()
|
||||
cwd_config = (Path.cwd() / "config.yaml").resolve()
|
||||
xdg_config_dir = get_xdg_base_dir("config").resolve()
|
||||
xdg_cache_dir = get_xdg_base_dir("cache").resolve()
|
||||
xdg_state_dir = get_xdg_base_dir("state").resolve()
|
||||
config_in_xdg_tree = config_file.is_relative_to(xdg_config_dir)
|
||||
|
||||
LOG.debug("Resolving ad files search directory for mode '%s': %s", mode, search_dir)
|
||||
return search_dir
|
||||
portable_hits:list[Path] = []
|
||||
xdg_hits:list[Path] = []
|
||||
|
||||
if config_file == cwd_config:
|
||||
portable_hits.append(cwd_config)
|
||||
if not config_in_xdg_tree:
|
||||
if (config_file.parent / ".temp").exists():
|
||||
portable_hits.append((config_file.parent / ".temp").resolve())
|
||||
if (config_file.parent / "downloaded-ads").exists():
|
||||
portable_hits.append((config_file.parent / "downloaded-ads").resolve())
|
||||
|
||||
if config_in_xdg_tree:
|
||||
xdg_hits.append(config_file)
|
||||
if not config_in_xdg_tree and (xdg_config_dir / "config.yaml").exists():
|
||||
xdg_hits.append((xdg_config_dir / "config.yaml").resolve())
|
||||
if (xdg_config_dir / "downloaded-ads").exists():
|
||||
xdg_hits.append((xdg_config_dir / "downloaded-ads").resolve())
|
||||
if (xdg_cache_dir / "browser-profile").exists():
|
||||
xdg_hits.append((xdg_cache_dir / "browser-profile").resolve())
|
||||
if (xdg_cache_dir / "diagnostics").exists():
|
||||
xdg_hits.append((xdg_cache_dir / "diagnostics").resolve())
|
||||
if (xdg_state_dir / "update_check_state.json").exists():
|
||||
xdg_hits.append((xdg_state_dir / "update_check_state.json").resolve())
|
||||
|
||||
portable_detected = len(portable_hits) > 0
|
||||
xdg_detected = len(xdg_hits) > 0
|
||||
|
||||
if portable_detected and xdg_detected:
|
||||
return "ambiguous", portable_hits, xdg_hits
|
||||
if portable_detected:
|
||||
return "portable", portable_hits, xdg_hits
|
||||
if xdg_detected:
|
||||
return "xdg", portable_hits, xdg_hits
|
||||
return "unknown", portable_hits, xdg_hits
|
||||
|
||||
|
||||
def get_downloaded_ads_path(mode:str | InstallationMode) -> Path:
|
||||
"""Get downloaded ads directory path.
|
||||
def _workspace_mode_resolution_error(
|
||||
config_file:Path,
|
||||
detected_mode:Literal["ambiguous", "unknown"],
|
||||
portable_hits:list[Path],
|
||||
xdg_hits:list[Path],
|
||||
) -> ValueError:
|
||||
def _format_hits(label:str, hits:list[Path]) -> str:
|
||||
if not hits:
|
||||
return f"{label}: {_('none')}"
|
||||
deduped = list(dict.fromkeys(hits))
|
||||
return f"{label}:\n- " + "\n- ".join(str(hit) for hit in deduped)
|
||||
|
||||
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
|
||||
guidance = _(
|
||||
"Cannot determine workspace mode for --config=%(config_file)s. "
|
||||
"Use --workspace-mode=portable or --workspace-mode=xdg.\n"
|
||||
"For cleanup guidance, see: %(url)s"
|
||||
) % {
|
||||
"config_file": config_file,
|
||||
"url": "https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/docs/CONFIGURATION.md#installation-modes",
|
||||
}
|
||||
details = f"{_format_hits(_('Portable footprint hits'), portable_hits)}\n{_format_hits(_('XDG footprint hits'), xdg_hits)}"
|
||||
if detected_mode == "ambiguous":
|
||||
return ValueError(f"{guidance}\n{_('Detected both portable and XDG footprints.')}\n{details}")
|
||||
return ValueError(f"{guidance}\n{_('Detected neither portable nor XDG footprints.')}\n{details}")
|
||||
|
||||
|
||||
def get_browser_profile_path(mode:str | InstallationMode, config_override:str | None = None) -> Path:
|
||||
"""Get browser profile directory path.
|
||||
def resolve_workspace(
|
||||
config_arg:str | None,
|
||||
logfile_arg:str | None,
|
||||
*,
|
||||
workspace_mode:InstallationMode | None,
|
||||
logfile_explicitly_provided:bool,
|
||||
log_basename:str,
|
||||
) -> Workspace:
|
||||
"""Resolve workspace paths from CLI flags and auto-detected installation mode."""
|
||||
config_path = Path(abspath(config_arg)).resolve() if config_arg else None
|
||||
mode = workspace_mode
|
||||
|
||||
Args:
|
||||
mode: Installation mode (portable or xdg)
|
||||
config_override: Optional config override path (takes precedence)
|
||||
if config_path and mode is None:
|
||||
detected_mode, portable_hits, xdg_hits = _detect_mode_from_footprints_with_hits(config_path)
|
||||
if detected_mode == "portable":
|
||||
mode = "portable"
|
||||
elif detected_mode == "xdg":
|
||||
mode = "xdg"
|
||||
else:
|
||||
raise _workspace_mode_resolution_error(
|
||||
config_path,
|
||||
detected_mode,
|
||||
portable_hits,
|
||||
xdg_hits,
|
||||
)
|
||||
|
||||
Returns:
|
||||
Path to browser profile directory
|
||||
if config_arg:
|
||||
if config_path is None or mode is None:
|
||||
raise RuntimeError("Workspace mode and config path must be resolved when --config is supplied")
|
||||
if mode == "portable":
|
||||
workspace = Workspace.for_config(config_path, log_basename)
|
||||
else:
|
||||
workspace = _build_xdg_workspace(log_basename, config_file_override = config_path)
|
||||
else:
|
||||
mode = mode or detect_installation_mode()
|
||||
if mode is None:
|
||||
mode = prompt_installation_mode()
|
||||
|
||||
Note:
|
||||
Creates the directory if it doesn't exist.
|
||||
"""
|
||||
mode = _normalize_mode(mode)
|
||||
if config_override:
|
||||
profile_path = Path(config_override).expanduser().resolve()
|
||||
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").resolve()
|
||||
LOG.debug("Resolving browser profile path for mode '%s': %s", mode, profile_path)
|
||||
else: # xdg
|
||||
profile_path = (get_xdg_base_dir("cache") / "browser-profile").resolve()
|
||||
LOG.debug("Resolving browser profile path for mode '%s': %s", mode, profile_path)
|
||||
workspace = Workspace.for_config((Path.cwd() / "config.yaml").resolve(), log_basename) if mode == "portable" else _build_xdg_workspace(log_basename)
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
_ensure_directory(profile_path, "browser profile directory")
|
||||
if logfile_explicitly_provided:
|
||||
workspace = replace(workspace, log_file = Path(abspath(logfile_arg)).resolve() if logfile_arg else None)
|
||||
|
||||
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
|
||||
return workspace
|
||||
|
||||
Reference in New Issue
Block a user