fix: add explicit workspace mode resolution for --config (#818)

This commit is contained in:
Jens
2026-02-11 05:35:41 +01:00
committed by GitHub
parent c212113638
commit 4282b05ff3
15 changed files with 1014 additions and 744 deletions

View File

@@ -7,7 +7,7 @@ import urllib.parse as urllib_parse
from datetime import datetime
from gettext import gettext as _
from pathlib import Path
from typing import Any, Final
from typing import Any, Final, cast
import certifi, colorama, nodriver # isort: skip
from nodriver.core.connection import ProtocolException
@@ -169,17 +169,17 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
self.config:Config
self.config_file_path = abspath("config.yaml")
self.config_explicitly_provided = False
self.installation_mode:xdg_paths.InstallationMode | None = None
self.workspace:xdg_paths.Workspace | None = None
self._config_arg:str | None = None
self._workspace_mode_arg:xdg_paths.InstallationMode | None = None
self.categories:dict[str, str] = {}
self.file_log:loggers.LogFileHandle | None = None
log_file_basename = is_frozen() and os.path.splitext(os.path.basename(sys.executable))[0] or self.__module__
self.log_file_path:str | None = abspath(f"{log_file_basename}.log")
self.log_file_basename = log_file_basename
self.log_file_explicitly_provided = False
self._log_basename = os.path.splitext(os.path.basename(sys.executable))[0] if is_frozen() else self.__module__
self.log_file_path:str | None = abspath(f"{self._log_basename}.log")
self._logfile_arg:str | None = None
self._logfile_explicitly_provided = False
self.command = "help"
self.ads_selector = "due"
@@ -193,70 +193,67 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
self.file_log = None
self.close_browser_session()
@property
def installation_mode_or_portable(self) -> xdg_paths.InstallationMode:
return self.installation_mode or "portable"
def get_version(self) -> str:
return __version__
def finalize_installation_mode(self) -> None:
def _workspace_or_raise(self) -> xdg_paths.Workspace:
if self.workspace is None:
raise AssertionError(_("Workspace must be resolved before command execution"))
return self.workspace
@property
def _update_check_state_path(self) -> Path:
return self._workspace_or_raise().state_dir / "update_check_state.json"
def _resolve_workspace(self) -> None:
"""
Finalize installation mode detection after CLI args are parsed.
Must be called after parse_args() to respect --config overrides.
Resolve workspace paths after CLI args are parsed.
"""
if self.command in {"help", "version"}:
if self.command in {"help", "version", "create-config"}:
return
# Check if config_file_path was already customized (by --config or tests)
default_portable_config = xdg_paths.get_config_file_path("portable").resolve()
config_path = Path(self.config_file_path).resolve() if self.config_file_path else None
config_was_customized = self.config_explicitly_provided or (config_path is not None and config_path != default_portable_config)
effective_config_arg = self._config_arg
effective_workspace_mode = self._workspace_mode_arg
if not effective_config_arg:
default_config = (Path.cwd() / "config.yaml").resolve()
if self.config_file_path and Path(self.config_file_path).resolve() != default_config:
effective_config_arg = self.config_file_path
if effective_workspace_mode is None:
# Backward compatibility for tests/programmatic assignment of config_file_path:
# infer a stable default from the configured path location.
config_path = Path(self.config_file_path).resolve()
xdg_config_dir = xdg_paths.get_xdg_base_dir("config").resolve()
effective_workspace_mode = "xdg" if config_path.is_relative_to(xdg_config_dir) else "portable"
if config_was_customized and self.config_file_path:
# Config path was explicitly set - detect mode based on it
LOG.debug("Detecting installation mode from explicit config path: %s", self.config_file_path)
try:
self.workspace = xdg_paths.resolve_workspace(
config_arg = effective_config_arg,
logfile_arg = self._logfile_arg,
workspace_mode = effective_workspace_mode,
logfile_explicitly_provided = self._logfile_explicitly_provided,
log_basename = self._log_basename,
)
except ValueError as exc:
LOG.error(str(exc))
sys.exit(2)
if config_path is not None and config_path == (Path.cwd() / "config.yaml").resolve():
# Explicit path points to CWD config
self.installation_mode = "portable"
LOG.debug("Explicit config is in CWD, using portable mode")
elif config_path is not None and config_path.is_relative_to(xdg_paths.get_xdg_base_dir("config").resolve()):
# Explicit path is within XDG config directory
self.installation_mode = "xdg"
LOG.debug("Explicit config is in XDG directory, using xdg mode")
else:
# Custom location - default to portable mode (all paths relative to config)
self.installation_mode = "portable"
LOG.debug("Explicit config is in custom location, defaulting to portable mode")
else:
# No explicit config - use auto-detection
LOG.debug("Detecting installation mode...")
self.installation_mode = xdg_paths.detect_installation_mode()
xdg_paths.ensure_directory(self.workspace.config_file.parent, "config directory")
if self.installation_mode is None:
# First run - prompt user
LOG.info("First run detected, prompting user for installation mode")
self.installation_mode = xdg_paths.prompt_installation_mode()
self.config_file_path = str(self.workspace.config_file)
self.log_file_path = str(self.workspace.log_file) if self.workspace.log_file else None
# Set config path based on detected mode
self.config_file_path = str(xdg_paths.get_config_file_path(self.installation_mode))
# Set log file path based on mode (unless explicitly overridden via --logfile)
using_default_portable_log = (
self.log_file_path is not None and Path(self.log_file_path).resolve() == xdg_paths.get_log_file_path(self.log_file_basename, "portable").resolve()
)
if not self.log_file_explicitly_provided and using_default_portable_log:
# Still using default portable path - update to match detected mode
self.log_file_path = str(xdg_paths.get_log_file_path(self.log_file_basename, self.installation_mode))
LOG.debug("Log file path: %s", self.log_file_path)
# Log installation mode and config location (INFO level for user visibility)
mode_display = "portable (current directory)" if self.installation_mode == "portable" else "system-wide (XDG directories)"
LOG.info("Installation mode: %s [%s]", mode_display, self.config_file_path)
LOG.info("Config: %s", self.workspace.config_file)
LOG.info("Workspace mode: %s", self.workspace.mode)
LOG.info("Workspace: %s", self.workspace.config_dir)
if loggers.is_debug(LOG):
LOG.debug("Log file: %s", self.workspace.log_file)
LOG.debug("State dir: %s", self.workspace.state_dir)
LOG.debug("Download dir: %s", self.workspace.download_dir)
LOG.debug("Browser profile: %s", self.workspace.browser_profile_dir)
LOG.debug("Diagnostics dir: %s", self.workspace.diagnostics_dir)
async def run(self, args:list[str]) -> None:
self.parse_args(args)
self.finalize_installation_mode()
self._resolve_workspace()
try:
match self.command:
case "help":
@@ -276,7 +273,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
self.configure_file_logging()
self.load_config()
# Check for updates on startup
checker = UpdateChecker(self.config, self.installation_mode_or_portable)
checker = UpdateChecker(self.config, self._update_check_state_path)
checker.check_for_updates()
self.load_ads()
LOG.info("############################################")
@@ -285,13 +282,13 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
case "update-check":
self.configure_file_logging()
self.load_config()
checker = UpdateChecker(self.config, self.installation_mode_or_portable)
checker = UpdateChecker(self.config, self._update_check_state_path)
checker.check_for_updates(skip_interval_check = True)
case "update-content-hash":
self.configure_file_logging()
self.load_config()
# Check for updates on startup
checker = UpdateChecker(self.config, self.installation_mode_or_portable)
checker = UpdateChecker(self.config, self._update_check_state_path)
checker.check_for_updates()
self.ads_selector = "all"
if ads := self.load_ads(exclude_ads_with_id = False):
@@ -304,7 +301,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
self.configure_file_logging()
self.load_config()
# Check for updates on startup
checker = UpdateChecker(self.config, self.installation_mode_or_portable)
checker = UpdateChecker(self.config, self._update_check_state_path)
checker.check_for_updates()
if not (
@@ -347,7 +344,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
self.configure_file_logging()
self.load_config()
# Check for updates on startup
checker = UpdateChecker(self.config, self.installation_mode_or_portable)
checker = UpdateChecker(self.config, self._update_check_state_path)
checker.check_for_updates()
if ads := self.load_ads():
await self.create_browser_session()
@@ -361,7 +358,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
self.configure_file_logging()
self.load_config()
# Check for updates on startup
checker = UpdateChecker(self.config, self.installation_mode_or_portable)
checker = UpdateChecker(self.config, self._update_check_state_path)
checker.check_for_updates()
# Default to all ads if no selector provided
@@ -385,7 +382,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
self.ads_selector = "new"
self.load_config()
# Check for updates on startup
checker = UpdateChecker(self.config, self.installation_mode_or_portable)
checker = UpdateChecker(self.config, self._update_check_state_path)
checker.check_for_updates()
await self.create_browser_session()
await self.login()
@@ -452,8 +449,9 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
Mit dieser Option können Sie bestimmte Anzeigen-IDs angeben, z. B. "--ads=1,2,3"
--force - Alias für '--ads=all'
--keep-old - Verhindert das Löschen alter Anzeigen bei erneuter Veröffentlichung
--config=<PATH> - Pfad zur YAML- oder JSON-Konfigurationsdatei (STANDARD: ./config.yaml)
--logfile=<PATH> - Pfad zur Protokolldatei (STANDARD: ./kleinanzeigen-bot.log)
--config=<PATH> - Pfad zur YAML- oder JSON-Konfigurationsdatei (ändert den Workspace-Modus nicht implizit)
--workspace-mode=portable|xdg - Überschreibt den Workspace-Modus für diesen Lauf
--logfile=<PATH> - Pfad zur Protokolldatei (STANDARD: vom aktiven Workspace-Modus abhängig)
--lang=en|de - Anzeigesprache (STANDARD: Systemsprache, wenn unterstützt, sonst Englisch)
-v, --verbose - Aktiviert detaillierte Ausgabe nur nützlich zur Fehlerbehebung
""".rstrip()
@@ -504,8 +502,9 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
Use this option to specify ad IDs, e.g. "--ads=1,2,3"
--force - alias for '--ads=all'
--keep-old - don't delete old ads on republication
--config=<PATH> - path to the config YAML or JSON file (DEFAULT: ./config.yaml)
--logfile=<PATH> - path to the logfile (DEFAULT: ./kleinanzeigen-bot.log)
--config=<PATH> - path to the config YAML or JSON file (does not implicitly change workspace mode)
--workspace-mode=portable|xdg - overrides workspace mode for this run
--logfile=<PATH> - path to the logfile (DEFAULT: depends on active workspace mode)
--lang=en|de - display language (STANDARD: system language if supported, otherwise English)
-v, --verbose - enables verbose output - only useful when troubleshooting issues
""".rstrip()
@@ -514,7 +513,11 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
def parse_args(self, args:list[str]) -> None:
try:
options, arguments = getopt.gnu_getopt(args[1:], "hv", ["ads=", "config=", "force", "help", "keep-old", "logfile=", "lang=", "verbose"])
options, arguments = getopt.gnu_getopt(
args[1:],
"hv",
["ads=", "config=", "force", "help", "keep-old", "logfile=", "lang=", "verbose", "workspace-mode="],
)
except getopt.error as ex:
LOG.error(ex.msg)
LOG.error("Use --help to display available options.")
@@ -527,13 +530,20 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
sys.exit(0)
case "--config":
self.config_file_path = abspath(value)
self.config_explicitly_provided = True
self._config_arg = value
case "--logfile":
if value:
self.log_file_path = abspath(value)
else:
self.log_file_path = None
self.log_file_explicitly_provided = True
self._logfile_arg = value
self._logfile_explicitly_provided = True
case "--workspace-mode":
mode = value.strip().lower()
if mode not in {"portable", "xdg"}:
LOG.error("Invalid --workspace-mode '%s'. Use 'portable' or 'xdg'.", value)
sys.exit(2)
self._workspace_mode_arg = cast(xdg_paths.InstallationMode, mode)
case "--ads":
self.ads_selector = value.strip().lower()
case "--force":
@@ -561,6 +571,9 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
if self.file_log:
return
if self.workspace and self.workspace.log_file:
xdg_paths.ensure_directory(self.workspace.log_file.parent, "log directory")
LOG.info("Logging to [%s]...", self.log_file_path)
self.file_log = loggers.configure_file_logging(self.log_file_path)
@@ -575,15 +588,21 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
if os.path.exists(self.config_file_path):
LOG.error("Config file %s already exists. Aborting creation.", self.config_file_path)
return
config_parent = self.workspace.config_file.parent if self.workspace else Path(self.config_file_path).parent
xdg_paths.ensure_directory(config_parent, "config directory")
default_config = Config.model_construct()
default_config.login.username = "changeme" # noqa: S105 placeholder for default config, not a real username
default_config.login.password = "changeme" # noqa: S105 placeholder for default config, not a real password
dicts.save_commented_model(
self.config_file_path,
default_config,
header = ("# yaml-language-server: $schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/main/schemas/config.schema.json"),
header = (
"# yaml-language-server: "
"$schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/main/schemas/config.schema.json"
),
exclude = {
"ad_defaults": {"description"}},
"ad_defaults": {"description"},
},
)
def load_config(self) -> None:
@@ -617,6 +636,8 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
self.browser_config.use_private_window = self.config.browser.use_private_window
if self.config.browser.user_data_dir:
self.browser_config.user_data_dir = abspath(self.config.browser.user_data_dir, relative_to = self.config_file_path)
elif self.workspace:
self.browser_config.user_data_dir = str(self.workspace.browser_profile_dir)
self.browser_config.profile_name = self.config.browser.profile_name
def __check_ad_republication(self, ad_cfg:Ad, ad_file_relative:str) -> bool:
@@ -962,10 +983,9 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
if diagnostics is not None and diagnostics.output_dir and diagnostics.output_dir.strip():
return Path(abspath(diagnostics.output_dir, relative_to = self.config_file_path)).resolve()
if self.installation_mode_or_portable == "xdg":
return xdg_paths.get_xdg_base_dir("cache") / "diagnostics"
return (Path.cwd() / ".temp" / "diagnostics").resolve()
workspace = self._workspace_or_raise()
xdg_paths.ensure_directory(workspace.diagnostics_dir, "diagnostics directory")
return workspace.diagnostics_dir
async def _capture_login_detection_diagnostics_if_enabled(self) -> None:
cfg = getattr(self.config, "diagnostics", None)
@@ -2031,7 +2051,9 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
LOG.warning("Skipping ad with non-numeric id: %s", published_ad.get("id"))
LOG.info("Loaded %s published ads.", len(published_ads_by_id))
ad_extractor = extract.AdExtractor(self.browser, self.config, self.installation_mode_or_portable, published_ads_by_id = published_ads_by_id)
workspace = self._workspace_or_raise()
xdg_paths.ensure_directory(workspace.download_dir, "downloaded ads directory")
ad_extractor = extract.AdExtractor(self.browser, self.config, workspace.download_dir, published_ads_by_id = published_ads_by_id)
# use relevant download routine
if self.ads_selector in {"all", "new"}: # explore ads overview for these two modes

View File

@@ -15,7 +15,7 @@ from kleinanzeigen_bot.model.ad_model import ContactPartial
from .model.ad_model import AdPartial
from .model.config_model import Config
from .utils import dicts, files, i18n, loggers, misc, reflect, xdg_paths
from .utils import dicts, files, i18n, loggers, misc, reflect
from .utils.web_scraping_mixin import Browser, By, Element, WebScrapingMixin
__all__ = [
@@ -37,15 +37,13 @@ class AdExtractor(WebScrapingMixin):
self,
browser:Browser,
config:Config,
installation_mode:xdg_paths.InstallationMode = "portable",
download_dir:Path,
published_ads_by_id:dict[int, dict[str, Any]] | None = None,
) -> None:
super().__init__()
self.browser = browser
self.config:Config = config
if installation_mode not in {"portable", "xdg"}:
raise ValueError(f"Unsupported installation mode: {installation_mode}")
self.installation_mode:xdg_paths.InstallationMode = installation_mode
self.download_dir = download_dir
self.published_ads_by_id:dict[int, dict[str, Any]] = published_ads_by_id or {}
async def download_ad(self, ad_id:int) -> None:
@@ -56,10 +54,8 @@ class AdExtractor(WebScrapingMixin):
:param ad_id: the ad ID
"""
# create sub-directory for ad(s) to download (if necessary):
download_dir = xdg_paths.get_downloaded_ads_path(self.installation_mode)
download_dir = self.download_dir
LOG.info("Using download directory: %s", download_dir)
# Note: xdg_paths.get_downloaded_ads_path() already creates the directory
# Extract ad info and determine final directory path
ad_cfg, final_dir = await self._extract_ad_page_info_with_directory_handling(download_dir, ad_id)

View File

@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
from kleinanzeigen_bot.utils import dicts, loggers, misc
from kleinanzeigen_bot.utils import dicts, loggers, misc, xdg_paths
from kleinanzeigen_bot.utils.pydantics import ContextualModel
LOG = loggers.get_logger(__name__)
@@ -117,6 +117,7 @@ class UpdateCheckState(ContextualModel):
if data["last_check"].tzinfo != datetime.timezone.utc:
data["last_check"] = data["last_check"].astimezone(datetime.timezone.utc)
data["last_check"] = data["last_check"].isoformat()
xdg_paths.ensure_directory(state_file.parent, "update check state directory")
dicts.save_dict(str(state_file), data)
except PermissionError:
LOG.warning("Permission denied when saving update check state to %s", state_file)

View File

@@ -25,6 +25,8 @@ kleinanzeigen_bot/__init__.py:
"Direct execution not supported. Use 'pdm run app'": "Direkte Ausführung nicht unterstützt. Bitte 'pdm run app' verwenden"
create_default_config:
"Config file %s already exists. Aborting creation.": "Konfigurationsdatei %s existiert bereits. Erstellung abgebrochen."
_workspace_or_raise:
"Workspace must be resolved before command execution": "Arbeitsbereich muss vor der Befehlsausführung aufgelöst werden"
configure_file_logging:
"Logging to [%s]...": "Protokollierung in [%s]..."
@@ -140,9 +142,15 @@ kleinanzeigen_bot/__init__.py:
find_and_click_extend_button:
"Found extend button on page %s": "'Verlängern'-Button auf Seite %s gefunden"
finalize_installation_mode:
"First run detected, prompting user for installation mode": "Erster Start erkannt, frage Benutzer nach Installationsmodus"
"Installation mode: %s [%s]": "Installationsmodus: %s [%s]"
_resolve_workspace:
"Config: %s": "Konfiguration: %s"
"Workspace mode: %s": "Arbeitsmodus: %s"
"Workspace: %s": "Arbeitsverzeichnis: %s"
parse_args:
"Use --help to display available options.": "Mit --help können die verfügbaren Optionen angezeigt werden."
"More than one command given: %s": "Mehr als ein Befehl angegeben: %s"
"Invalid --workspace-mode '%s'. Use 'portable' or 'xdg'.": "Ungültiger --workspace-mode '%s'. Verwenden Sie 'portable' oder 'xdg'."
publish_ads:
"Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..."
@@ -241,10 +249,6 @@ kleinanzeigen_bot/__init__.py:
"Downloaded ad with id %d": "Anzeige mit der ID %d heruntergeladen"
"The page with the id %d does not exist!": "Die Seite mit der ID %d existiert nicht!"
parse_args:
"Use --help to display available options.": "Mit --help können die verfügbaren Optionen angezeigt werden."
"More than one command given: %s": "Mehr als ein Befehl angegeben: %s"
run:
"DONE: No configuration errors found.": "FERTIG: Keine Konfigurationsfehler gefunden."
"DONE: No active ads found.": "FERTIG: Keine aktiven Anzeigen gefunden."
@@ -684,7 +688,7 @@ kleinanzeigen_bot/utils/diagnostics.py:
#################################################
kleinanzeigen_bot/utils/xdg_paths.py:
#################################################
_ensure_directory:
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"
@@ -693,8 +697,18 @@ kleinanzeigen_bot/utils/xdg_paths.py:
"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)"
"[2] User directories (per-user standard locations)": "[2] Benutzerverzeichnisse (pro Benutzer, standardisierte Pfade)"
"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."
resolve_workspace: {}
_format_hits:
"none": "keine"
_workspace_mode_resolution_error:
? "Cannot determine workspace mode for --config=%(config_file)s. Use --workspace-mode=portable or --workspace-mode=xdg.\nFor cleanup guidance, see: %(url)s"
: "Arbeitsmodus für --config=%(config_file)s konnte nicht bestimmt werden. Verwende --workspace-mode=portable oder --workspace-mode=xdg.\nHinweise zur Bereinigung: %(url)s"
"Portable footprint hits": "Gefundene portable Spuren"
"XDG footprint hits": "Gefundene XDG-Spuren"
"Detected both portable and XDG footprints.": "Sowohl portable als auch XDG-Spuren wurden gefunden."
"Detected neither portable nor XDG footprints.": "Weder portable noch XDG-Spuren wurden gefunden."

View File

@@ -12,6 +12,8 @@ import colorama
import requests
if TYPE_CHECKING:
from pathlib import Path
from kleinanzeigen_bot.model.config_model import Config
try:
@@ -20,7 +22,6 @@ except ImportError:
__version__ = "unknown"
from kleinanzeigen_bot.model.update_check_state import UpdateCheckState
from kleinanzeigen_bot.utils import xdg_paths
logger = logging.getLogger(__name__)
@@ -30,16 +31,15 @@ colorama.init()
class UpdateChecker:
"""Checks for updates to the bot."""
def __init__(self, config:"Config", installation_mode:str | xdg_paths.InstallationMode = "portable") -> None:
def __init__(self, config:"Config", state_file:"Path") -> None:
"""Initialize the update checker.
Args:
config: The bot configuration.
installation_mode: Installation mode (portable/xdg).
state_file: Path to the update-check state JSON file.
"""
self.config = config
self.state_file = xdg_paths.get_update_check_state_path(installation_mode)
# Note: xdg_paths handles directory creation
self.state_file = state_file
self.state = UpdateCheckState.load(self.state_file)
def get_local_version(self) -> str | None:

View File

@@ -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")

View File

@@ -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