From 4282b05ff38e6e3fde2db444bd1ed6a86498a19b Mon Sep 17 00:00:00 2001 From: Jens <1742418+1cu@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:35:41 +0100 Subject: [PATCH] fix: add explicit workspace mode resolution for --config (#818) --- README.md | 33 +- docs/CONFIGURATION.md | 45 +- src/kleinanzeigen_bot/__init__.py | 182 ++--- src/kleinanzeigen_bot/extract.py | 12 +- .../model/update_check_state.py | 3 +- .../resources/translations.de.yaml | 32 +- src/kleinanzeigen_bot/update_checker.py | 10 +- .../utils/web_scraping_mixin.py | 10 +- src/kleinanzeigen_bot/utils/xdg_paths.py | 333 ++++----- tests/smoke/test_smoke_health.py | 42 +- tests/unit/test_extract.py | 13 +- tests/unit/test_init.py | 197 +++++- tests/unit/test_update_checker.py | 114 ++-- tests/unit/test_web_scraping_mixin.py | 87 +++ tests/unit/test_xdg_paths.py | 645 ++++++++---------- 15 files changed, 1014 insertions(+), 744 deletions(-) diff --git a/README.md b/README.md index 8b7c107..fad419c 100644 --- a/README.md +++ b/README.md @@ -249,8 +249,9 @@ Options: * : provide one or several ads by ID to update, like e.g. "--ads=1,2,3" --force - alias for '--ads=all' --keep-old - don't delete old ads on republication - --config= - path to the config YAML or JSON file (DEFAULT: ./config.yaml) - --logfile= - path to the logfile (DEFAULT: ./kleinanzeigen-bot.log) + --config= - 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 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 ``` @@ -263,19 +264,31 @@ Limitation of `download`: It's only possible to extract the cheapest given shipp All configuration files can be in YAML or JSON format. -### Installation modes (portable vs. system-wide) +### Installation modes (portable vs. user directories) On first run, the app may ask which installation mode to use. In non-interactive environments (CI/headless), it defaults to portable mode and will not prompt. -The `--config` and `--logfile` flags override only their specific paths. They do not change the chosen installation mode or other mode-dependent paths (downloads, state files, etc.). +Path resolution rules: + +- Runtime files are mode-dependent write locations (for example, logfile, update state, browser profile/cache, diagnostics, and downloaded ads). +- `--config` selects only the config file; it does not silently switch workspace mode. +- `--workspace-mode=portable`: runtime files are rooted next to the active config file (or the current working directory if no `--config` is supplied). +- `--workspace-mode=xdg`: runtime files use OS-standard user directories. +- `--config` without `--workspace-mode`: mode is inferred from existing footprints; on ambiguity/unknown, the command fails with guidance (for example: `Could not infer workspace mode for --config ...`) and asks you to rerun with `--workspace-mode=portable` or `--workspace-mode=xdg`. + +Examples: + +- `kleinanzeigen-bot --config /sync/dropbox/config1.yaml verify` (no `--workspace-mode`): mode is inferred from detected footprints; if both portable and user-directories footprints are found (or none are found), the command fails and lists the found paths. +- `kleinanzeigen-bot --workspace-mode=portable --config /sync/dropbox/config1.yaml verify`: runtime files are rooted at `/sync/dropbox/` (for example `/sync/dropbox/.temp/` and `/sync/dropbox/downloaded-ads/`). +- `kleinanzeigen-bot --workspace-mode=xdg --config /sync/dropbox/config1.yaml verify`: config is read from `/sync/dropbox/config1.yaml`, while runtime files stay in user directories (for example Linux `~/.config/kleinanzeigen-bot/`, `~/.local/state/kleinanzeigen-bot/`, `~/.cache/kleinanzeigen-bot/`). 1. **Portable mode (recommended for most users, especially on Windows):** - - Stores config, logs, downloads, and state in the current directory + - Stores config, logs, downloads, and state in the current working directory - No admin permissions required - Easy backup/migration; works from USB drives -1. **System-wide mode (advanced users / multi-user setups):** +1. **User directories mode (advanced users / multi-user setups):** - Stores files in OS-standard locations - Cleaner directory structure; better separation from working directory @@ -283,9 +296,11 @@ The `--config` and `--logfile` flags override only their specific paths. They do **OS notes (brief):** -- **Windows:** System-wide uses AppData (Roaming/Local); portable keeps everything beside the `.exe`. -- **Linux:** System-wide follows XDG Base Directory spec; portable stays in the current working directory. -- **macOS:** System-wide uses `~/Library/Application Support/kleinanzeigen-bot` (and related dirs); portable stays in the current directory. +- **Windows:** User directories mode uses AppData (Roaming/Local); portable keeps everything beside the `.exe`. +- **Linux:** User directories mode uses `~/.config/kleinanzeigen-bot/config.yaml`, `~/.local/state/kleinanzeigen-bot/`, and `~/.cache/kleinanzeigen-bot/`; portable uses `./config.yaml`, `./.temp/`, and `./downloaded-ads/`. +- **macOS:** User directories mode uses `~/Library/Application Support/kleinanzeigen-bot/config.yaml` (config), `~/Library/Application Support/kleinanzeigen-bot/` (state/runtime), and `~/Library/Caches/kleinanzeigen-bot/` (cache/diagnostics); portable stays in the current working directory. + +If you have mixed legacy footprints (portable + XDG), pass an explicit mode (for example `--workspace-mode=portable`) and then clean up unused files. See [Configuration: Installation Modes](docs/CONFIGURATION.md#installation-modes). ### 1) Main configuration ⚙️ diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7188c01..508ca0b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -59,12 +59,14 @@ Full documentation for ad YAML files including automatic price reduction, descri ## File Location -The bot looks for `config.yaml` in the current directory by default. You can specify a different location using the `--config` command line option: +The bot looks for `config.yaml` in the current directory by default. You can specify a different location using `--config`: ```bash kleinanzeigen-bot --config /path/to/config.yaml publish ``` +`--config` selects the configuration file only. Workspace behavior is controlled by installation mode (`portable` or `xdg`) and can be overridden via `--workspace-mode=portable|xdg` (see [Installation Modes](#installation-modes)). + Valid file extensions: `.json`, `.yaml`, `.yml` ## Configuration Structure @@ -302,15 +304,16 @@ The bot uses a layered approach to detect login state, prioritizing stealth over **Output locations (default):** -- **Portable mode**: `./.temp/diagnostics/` -- **System-wide mode (XDG)**: `~/.cache/kleinanzeigen-bot/diagnostics/` (Linux) or `~/Library/Caches/kleinanzeigen-bot/diagnostics/` (macOS) +- **Portable mode + `--config /path/to/config.yaml`**: `/path/to/.temp/diagnostics/` (portable runtime files are placed next to the selected config file) +- **Portable mode without `--config`**: `./.temp/diagnostics/` (current working directory) +- **User directories mode**: `~/.cache/kleinanzeigen-bot/diagnostics/` (Linux), `~/Library/Caches/kleinanzeigen-bot/diagnostics/` (macOS), or `%LOCALAPPDATA%\kleinanzeigen-bot\Cache\diagnostics\` (Windows) - **Custom**: Path resolved relative to your `config.yaml` if `output_dir` is specified > **⚠️ PII Warning:** HTML dumps, JSON payloads, and log copies may contain PII. Typical examples include account email, ad titles/descriptions, contact info, and prices. Log copies are produced by `capture_log_copy` when diagnostics capture runs, such as `capture_on.publish` or `capture_on.login_detection`. Review or redact these artifacts before sharing them publicly. ## Installation Modes -On first run, the app may ask which installation mode to use. +On first run, when the `--workspace-mode` flag is not provided, the app may ask which installation mode to use. In non-interactive environments, it defaults to portable mode. 1. **Portable mode (recommended for most users, especially on Windows):** @@ -318,7 +321,7 @@ On first run, the app may ask which installation mode to use. - No admin permissions required - Easy backup/migration; works from USB drives -2. **System-wide mode (advanced users / multi-user setups):** +2. **User directories mode (advanced users / multi-user setups):** - Stores files in OS-standard locations - Cleaner directory structure; better separation from working directory @@ -326,9 +329,35 @@ On first run, the app may ask which installation mode to use. **OS notes:** -- **Windows:** System-wide uses AppData (Roaming/Local); portable keeps everything beside the `.exe`. -- **Linux:** System-wide follows XDG Base Directory spec; portable stays in the current working directory. -- **macOS:** System-wide uses `~/Library/Application Support/kleinanzeigen-bot` (and related dirs); portable stays in the current directory. +- **Windows:** User directories mode uses AppData (Roaming/Local); portable keeps everything beside the `.exe`. +- **Linux:** User directories mode uses `~/.config/kleinanzeigen-bot/config.yaml`, `~/.local/state/kleinanzeigen-bot/`, and `~/.cache/kleinanzeigen-bot/`; portable stays in the current working directory (for example `./config.yaml`, `./.temp/`, `./downloaded-ads/`). +- **macOS:** User directories mode uses `~/Library/Application Support/kleinanzeigen-bot/config.yaml` (config), `~/Library/Application Support/kleinanzeigen-bot/` (state/runtime), and `~/Library/Caches/kleinanzeigen-bot/` (cache/diagnostics); portable stays in the current directory. + +### Mixed footprint cleanup + +If both portable and XDG footprints exist, `--config` without `--workspace-mode` is intentionally rejected to avoid silent behavior changes. + +A footprint is the set of files/directories the bot creates for one mode (configuration file, runtime state/cache directories, and `downloaded-ads`). + +Use one explicit run to choose a mode: + +```bash +kleinanzeigen-bot --workspace-mode=portable --config /path/to/config.yaml verify +``` + +or + +```bash +kleinanzeigen-bot --workspace-mode=xdg --config /path/to/config.yaml verify +``` + +Then remove the unused footprint directories/files to make auto-detection unambiguous for future runs. + +- Remove **portable footprint** items in your working location: `config.yaml`, `.temp/` (Windows: `.temp\`), and `downloaded-ads/` (Windows: `downloaded-ads\`). Back up or move `config.yaml` to your desired location before deleting it. +- Remove **user directories footprint** items: + Linux: `~/.config/kleinanzeigen-bot/`, `~/.local/state/kleinanzeigen-bot/`, `~/.cache/kleinanzeigen-bot/`. + macOS: `~/Library/Application Support/kleinanzeigen-bot/`, `~/Library/Caches/kleinanzeigen-bot/`. + Windows: `%APPDATA%\kleinanzeigen-bot\`, `%LOCALAPPDATA%\kleinanzeigen-bot\`, `%LOCALAPPDATA%\kleinanzeigen-bot\Cache\`. ## Getting Current Defaults diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index 9c397a5..f274d44 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -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= - Pfad zur YAML- oder JSON-Konfigurationsdatei (STANDARD: ./config.yaml) - --logfile= - Pfad zur Protokolldatei (STANDARD: ./kleinanzeigen-bot.log) + --config= - 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= - 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 to the config YAML or JSON file (DEFAULT: ./config.yaml) - --logfile= - path to the logfile (DEFAULT: ./kleinanzeigen-bot.log) + --config= - 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 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 diff --git a/src/kleinanzeigen_bot/extract.py b/src/kleinanzeigen_bot/extract.py index a41175b..da51f0e 100644 --- a/src/kleinanzeigen_bot/extract.py +++ b/src/kleinanzeigen_bot/extract.py @@ -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) diff --git a/src/kleinanzeigen_bot/model/update_check_state.py b/src/kleinanzeigen_bot/model/update_check_state.py index e974af9..2a80558 100644 --- a/src/kleinanzeigen_bot/model/update_check_state.py +++ b/src/kleinanzeigen_bot/model/update_check_state.py @@ -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) diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index ba31d94..17fc093 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -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." diff --git a/src/kleinanzeigen_bot/update_checker.py b/src/kleinanzeigen_bot/update_checker.py index a01929f..1319932 100644 --- a/src/kleinanzeigen_bot/update_checker.py +++ b/src/kleinanzeigen_bot/update_checker.py @@ -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: diff --git a/src/kleinanzeigen_bot/utils/web_scraping_mixin.py b/src/kleinanzeigen_bot/utils/web_scraping_mixin.py index 279e75d..f81e7ff 100644 --- a/src/kleinanzeigen_bot/utils/web_scraping_mixin.py +++ b/src/kleinanzeigen_bot/utils/web_scraping_mixin.py @@ -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") diff --git a/src/kleinanzeigen_bot/utils/xdg_paths.py b/src/kleinanzeigen_bot/utils/xdg_paths.py index 6f29a7f..70c0aa0 100644 --- a/src/kleinanzeigen_bot/utils/xdg_paths.py +++ b/src/kleinanzeigen_bot/utils/xdg_paths.py @@ -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 diff --git a/tests/smoke/test_smoke_health.py b/tests/smoke/test_smoke_health.py index 861633e..1fec612 100644 --- a/tests/smoke/test_smoke_health.py +++ b/tests/smoke/test_smoke_health.py @@ -14,7 +14,7 @@ import os import re from dataclasses import dataclass from pathlib import Path -from typing import Any, Callable +from typing import Any, Callable, Mapping from unittest.mock import patch import pytest @@ -35,9 +35,19 @@ class CLIResult: stderr:str -def invoke_cli(args:list[str], cwd:Path | None = None) -> CLIResult: +def invoke_cli( + args:list[str], + cwd:Path | None = None, + env_overrides:Mapping[str, str] | None = None, +) -> CLIResult: """ Run the kleinanzeigen-bot CLI in-process and capture stdout/stderr. + + Args: + args: CLI arguments passed to ``kleinanzeigen_bot.main``. + cwd: Optional working directory for this in-process CLI run. + env_overrides: Optional environment variable overrides merged into the + current environment for the run (useful to isolate HOME/XDG paths). """ stdout = io.StringIO() stderr = io.StringIO() @@ -70,6 +80,9 @@ def invoke_cli(args:list[str], cwd:Path | None = None) -> CLIResult: stack.enter_context(patch("kleinanzeigen_bot.atexit.register", capture_register)) stack.enter_context(contextlib.redirect_stdout(stdout)) stack.enter_context(contextlib.redirect_stderr(stderr)) + effective_env_overrides = env_overrides if env_overrides is not None else _default_smoke_env(cwd) + if effective_env_overrides is not None: + stack.enter_context(patch.dict(os.environ, effective_env_overrides)) try: kleinanzeigen_bot.main(["kleinanzeigen-bot", *args]) except SystemExit as exc: @@ -83,6 +96,29 @@ def invoke_cli(args:list[str], cwd:Path | None = None) -> CLIResult: set_current_locale(previous_locale) +def _xdg_env_overrides(tmp_path:Path) -> dict[str, str]: + """Create temporary HOME/XDG environment overrides for isolated smoke test runs.""" + home = tmp_path / "home" + xdg_config = tmp_path / "xdg" / "config" + xdg_state = tmp_path / "xdg" / "state" + xdg_cache = tmp_path / "xdg" / "cache" + for path in (home, xdg_config, xdg_state, xdg_cache): + path.mkdir(parents = True, exist_ok = True) + return { + "HOME": os.fspath(home), + "XDG_CONFIG_HOME": os.fspath(xdg_config), + "XDG_STATE_HOME": os.fspath(xdg_state), + "XDG_CACHE_HOME": os.fspath(xdg_cache), + } + + +def _default_smoke_env(cwd:Path | None) -> dict[str, str] | None: + """Isolate HOME/XDG paths to temporary directories during smoke CLI calls.""" + if cwd is None: + return None + return _xdg_env_overrides(cwd) + + @pytest.fixture(autouse = True) def disable_update_checker(monkeypatch:pytest.MonkeyPatch) -> None: """Prevent smoke tests from hitting GitHub for update checks.""" @@ -188,7 +224,7 @@ def test_cli_subcommands_with_config_formats( yaml.dump(config_dict, f) elif serializer is not None: config_path.write_text(serializer(config_dict), encoding = "utf-8") - args = [subcommand, "--config", str(config_path)] + args = [subcommand, "--config", str(config_path), "--workspace-mode", "portable"] result = invoke_cli(args, cwd = tmp_path) assert result.returncode == 0 out = (result.stdout + "\n" + result.stderr).lower() diff --git a/tests/unit/test_extract.py b/tests/unit/test_extract.py index 1c76214..58db50c 100644 --- a/tests/unit/test_extract.py +++ b/tests/unit/test_extract.py @@ -46,7 +46,7 @@ def test_extractor(browser_mock:MagicMock, test_bot_config:Config) -> extract_mo - browser_mock: Used to mock browser interactions - test_bot_config: Used to initialize the extractor with a valid configuration """ - return extract_module.AdExtractor(browser_mock, test_bot_config) + return extract_module.AdExtractor(browser_mock, test_bot_config, Path("downloaded-ads")) class TestAdExtractorBasics: @@ -54,9 +54,10 @@ class TestAdExtractorBasics: def test_constructor(self, browser_mock:MagicMock, test_bot_config:Config) -> None: """Test the constructor of extract_module.AdExtractor""" - extractor = extract_module.AdExtractor(browser_mock, test_bot_config) + extractor = extract_module.AdExtractor(browser_mock, test_bot_config, Path("downloaded-ads")) assert extractor.browser == browser_mock assert extractor.config == test_bot_config + assert extractor.download_dir == Path("downloaded-ads") @pytest.mark.parametrize( ("url", "expected_id"), @@ -950,7 +951,7 @@ class TestAdExtractorCategory: def extractor(self, test_bot_config:Config) -> extract_module.AdExtractor: browser_mock = MagicMock(spec = Browser) config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}}) - return extract_module.AdExtractor(browser_mock, config) + return extract_module.AdExtractor(browser_mock, config, Path("downloaded-ads")) @pytest.mark.asyncio # pylint: disable=protected-access @@ -1092,7 +1093,7 @@ class TestAdExtractorContact: def extractor(self, test_bot_config:Config) -> extract_module.AdExtractor: browser_mock = MagicMock(spec = Browser) config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}}) - return extract_module.AdExtractor(browser_mock, config) + return extract_module.AdExtractor(browser_mock, config, Path("downloaded-ads")) @pytest.mark.asyncio # pylint: disable=protected-access @@ -1163,7 +1164,7 @@ class TestAdExtractorDownload: def extractor(self, test_bot_config:Config) -> extract_module.AdExtractor: browser_mock = MagicMock(spec = Browser) config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}}) - return extract_module.AdExtractor(browser_mock, config) + return extract_module.AdExtractor(browser_mock, config, Path("downloaded-ads")) @pytest.mark.asyncio async def test_download_ad(self, extractor:extract_module.AdExtractor, tmp_path:Path) -> None: @@ -1172,9 +1173,9 @@ class TestAdExtractorDownload: download_base = tmp_path / "downloaded-ads" final_dir = download_base / "ad_12345_Test Advertisement Title" yaml_path = final_dir / "ad_12345.yaml" + extractor.download_dir = download_base with ( - patch("kleinanzeigen_bot.extract.xdg_paths.get_downloaded_ads_path", return_value = download_base), patch("kleinanzeigen_bot.extract.dicts.save_dict", autospec = True) as mock_save_dict, patch.object(extractor, "_extract_ad_page_info_with_directory_handling", new_callable = AsyncMock) as mock_extract_with_dir, ): diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 3fbc38d..d4d60ea 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ import copy, fnmatch, io, json, logging, os, tempfile # isort: skip -from collections.abc import Generator +from collections.abc import Callable, Generator from contextlib import redirect_stdout from datetime import timedelta from pathlib import Path, PureWindowsPath @@ -16,7 +16,7 @@ from kleinanzeigen_bot import LOG, PUBLISH_MAX_RETRIES, AdUpdateStrategy, Kleina from kleinanzeigen_bot._version import __version__ from kleinanzeigen_bot.model.ad_model import Ad from kleinanzeigen_bot.model.config_model import AdDefaults, Config, DiagnosticsConfig, PublishingConfig -from kleinanzeigen_bot.utils import dicts, loggers +from kleinanzeigen_bot.utils import dicts, loggers, xdg_paths from kleinanzeigen_bot.utils.web_scraping_mixin import By, Element @@ -108,6 +108,26 @@ def mock_config_setup(test_bot:KleinanzeigenBot) -> Generator[None]: yield +def _make_fake_resolve_workspace( + captured_mode:dict[str, xdg_paths.InstallationMode | None], + workspace:xdg_paths.Workspace, +) -> Callable[..., xdg_paths.Workspace]: + """Create a fake resolve_workspace that captures the workspace_mode argument.""" + + def fake_resolve_workspace( + config_arg:str | None, + logfile_arg:str | None, + *, + workspace_mode:xdg_paths.InstallationMode | None, + logfile_explicitly_provided:bool, + log_basename:str, + ) -> xdg_paths.Workspace: + captured_mode["value"] = workspace_mode + return workspace + + return fake_resolve_workspace + + class TestKleinanzeigenBotInitialization: """Tests for KleinanzeigenBot initialization and basic functionality.""" @@ -126,28 +146,124 @@ class TestKleinanzeigenBotInitialization: with patch("kleinanzeigen_bot.__version__", "1.2.3"): assert test_bot.get_version() == "1.2.3" - def test_finalize_installation_mode_skips_help(self, test_bot:KleinanzeigenBot) -> None: - """Ensure finalize_installation_mode returns early for help.""" + def test_resolve_workspace_skips_help(self, test_bot:KleinanzeigenBot) -> None: + """Ensure workspace resolution returns early for help.""" test_bot.command = "help" - test_bot.installation_mode = None - test_bot.finalize_installation_mode() - assert test_bot.installation_mode is None + test_bot.workspace = None + test_bot._resolve_workspace() + assert test_bot.workspace is None + + def test_resolve_workspace_skips_create_config(self, test_bot:KleinanzeigenBot) -> None: + """Ensure workspace resolution returns early for create-config.""" + test_bot.command = "create-config" + test_bot.workspace = None + test_bot._resolve_workspace() + assert test_bot.workspace is None + + def test_resolve_workspace_exits_on_workspace_resolution_error(self, test_bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None: + """Workspace resolution errors should terminate with code 2.""" + caplog.set_level(logging.ERROR) + test_bot.command = "verify" + + with ( + patch("kleinanzeigen_bot.xdg_paths.resolve_workspace", side_effect = ValueError("workspace error")), + pytest.raises(SystemExit) as exc_info, + ): + test_bot._resolve_workspace() + + assert exc_info.value.code == 2 + assert "workspace error" in caplog.text + + def test_resolve_workspace_fails_fast_when_config_parent_cannot_be_created(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None: + """Workspace resolution should fail immediately when config directory creation fails.""" + test_bot.command = "verify" + workspace = xdg_paths.Workspace.for_config(tmp_path / "blocked" / "config.yaml", "kleinanzeigen-bot") + + with ( + patch("kleinanzeigen_bot.xdg_paths.resolve_workspace", return_value = workspace), + patch("kleinanzeigen_bot.xdg_paths.ensure_directory", side_effect = OSError("mkdir denied")), + pytest.raises(OSError, match = "mkdir denied"), + ): + test_bot._resolve_workspace() + + def test_resolve_workspace_programmatic_config_in_xdg_defaults_to_xdg(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None: + """Programmatic config_file_path in XDG config tree should default workspace mode to xdg.""" + test_bot.command = "verify" + xdg_dirs = { + "config": tmp_path / "xdg-config" / xdg_paths.APP_NAME, + "state": tmp_path / "xdg-state" / xdg_paths.APP_NAME, + "cache": tmp_path / "xdg-cache" / xdg_paths.APP_NAME, + } + for path in xdg_dirs.values(): + path.mkdir(parents = True, exist_ok = True) + config_path = xdg_dirs["config"] / "config.yaml" + config_path.touch() + test_bot.config_file_path = str(config_path) + + workspace = xdg_paths.Workspace.for_config(tmp_path / "resolved" / "config.yaml", "kleinanzeigen-bot") + captured_mode:dict[str, xdg_paths.InstallationMode | None] = {"value": None} + + with ( + patch("kleinanzeigen_bot.xdg_paths.get_xdg_base_dir", side_effect = lambda category: xdg_dirs[category]), + patch("kleinanzeigen_bot.xdg_paths.resolve_workspace", side_effect = _make_fake_resolve_workspace(captured_mode, workspace)), + patch("kleinanzeigen_bot.xdg_paths.ensure_directory"), + ): + test_bot._resolve_workspace() + + assert captured_mode["value"] == "xdg" + + def test_resolve_workspace_programmatic_config_outside_xdg_defaults_to_portable(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None: + """Programmatic config_file_path outside XDG config tree should default workspace mode to portable.""" + test_bot.command = "verify" + xdg_dirs = { + "config": tmp_path / "xdg-config" / xdg_paths.APP_NAME, + "state": tmp_path / "xdg-state" / xdg_paths.APP_NAME, + "cache": tmp_path / "xdg-cache" / xdg_paths.APP_NAME, + } + for path in xdg_dirs.values(): + path.mkdir(parents = True, exist_ok = True) + config_path = tmp_path / "external" / "config.yaml" + config_path.parent.mkdir(parents = True, exist_ok = True) + config_path.touch() + test_bot.config_file_path = str(config_path) + + workspace = xdg_paths.Workspace.for_config(tmp_path / "resolved" / "config.yaml", "kleinanzeigen-bot") + captured_mode:dict[str, xdg_paths.InstallationMode | None] = {"value": None} + + with ( + patch("kleinanzeigen_bot.xdg_paths.get_xdg_base_dir", side_effect = lambda category: xdg_dirs[category]), + patch("kleinanzeigen_bot.xdg_paths.resolve_workspace", side_effect = _make_fake_resolve_workspace(captured_mode, workspace)), + patch("kleinanzeigen_bot.xdg_paths.ensure_directory"), + ): + test_bot._resolve_workspace() + + assert captured_mode["value"] == "portable" + + def test_create_default_config_creates_parent_without_workspace(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None: + """create_default_config should create parent directories when no workspace is set.""" + config_path = tmp_path / "nested" / "config.yaml" + test_bot.workspace = None + test_bot.config_file_path = str(config_path) + + test_bot.create_default_config() + + assert config_path.exists() @pytest.mark.asyncio @pytest.mark.parametrize("command", ["verify", "update-check", "update-content-hash", "publish", "delete", "download"]) - async def test_run_uses_installation_mode_for_update_checker(self, test_bot:KleinanzeigenBot, command:str) -> None: - """Ensure UpdateChecker is initialized with the detected installation mode.""" - update_checker_calls:list[tuple[Config, str | None]] = [] + async def test_run_uses_workspace_state_file_for_update_checker(self, test_bot:KleinanzeigenBot, command:str, tmp_path:Path) -> None: + """Ensure UpdateChecker is initialized with the workspace state file.""" + update_checker_calls:list[tuple[Config, Path]] = [] class DummyUpdateChecker: - def __init__(self, config:Config, installation_mode:str | None) -> None: - update_checker_calls.append((config, installation_mode)) + def __init__(self, config:Config, state_file:Path) -> None: + update_checker_calls.append((config, state_file)) def check_for_updates(self, *_args:Any, **_kwargs:Any) -> None: return None - def set_installation_mode() -> None: - test_bot.installation_mode = "xdg" + def set_workspace() -> None: + test_bot.workspace = xdg_paths.Workspace.for_config(tmp_path / "config.yaml", "kleinanzeigen-bot") with ( patch.object(test_bot, "configure_file_logging"), @@ -157,17 +273,18 @@ class TestKleinanzeigenBotInitialization: patch.object(test_bot, "login", new_callable = AsyncMock), patch.object(test_bot, "download_ads", new_callable = AsyncMock), patch.object(test_bot, "close_browser_session"), - patch.object(test_bot, "finalize_installation_mode", side_effect = set_installation_mode), + patch.object(test_bot, "_resolve_workspace", side_effect = set_workspace), patch("kleinanzeigen_bot.UpdateChecker", DummyUpdateChecker), ): await test_bot.run(["app", command]) - assert update_checker_calls == [(test_bot.config, "xdg")] + expected_state_path = (tmp_path / "config.yaml").resolve().parent / ".temp" / "update_check_state.json" + assert update_checker_calls == [(test_bot.config, expected_state_path)] @pytest.mark.asyncio - async def test_download_ads_passes_installation_mode_and_published_ads(self, test_bot:KleinanzeigenBot) -> None: - """Ensure download_ads wires installation mode and published_ads_by_id into AdExtractor.""" - test_bot.installation_mode = "xdg" + async def test_download_ads_passes_download_dir_and_published_ads(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None: + """Ensure download_ads wires download_dir and published_ads_by_id into AdExtractor.""" + test_bot.workspace = xdg_paths.Workspace.for_config(tmp_path / "config.yaml", "kleinanzeigen-bot") test_bot.ads_selector = "all" test_bot.browser = MagicMock() @@ -184,7 +301,10 @@ class TestKleinanzeigenBotInitialization: # Verify published_ads_by_id is built correctly and passed to extractor mock_extractor.assert_called_once_with( - test_bot.browser, test_bot.config, "xdg", published_ads_by_id = {123: mock_published_ads[0], 456: mock_published_ads[1]} + test_bot.browser, + test_bot.config, + test_bot.workspace.download_dir, + published_ads_by_id = {123: mock_published_ads[0], 456: mock_published_ads[1]}, ) @@ -894,6 +1014,17 @@ class TestKleinanzeigenBotArgParsing: assert test_bot.log_file_path is not None assert "test.log" in test_bot.log_file_path + def test_parse_args_workspace_mode(self, test_bot:KleinanzeigenBot) -> None: + """Test parsing workspace mode option.""" + test_bot.parse_args(["script.py", "--workspace-mode=xdg", "help"]) + assert test_bot._workspace_mode_arg == "xdg" + + def test_parse_args_workspace_mode_invalid(self, test_bot:KleinanzeigenBot) -> None: + """Test invalid workspace mode exits with error.""" + with pytest.raises(SystemExit) as exc_info: + test_bot.parse_args(["script.py", "--workspace-mode=invalid", "help"]) + assert exc_info.value.code == 2 + def test_parse_args_ads_selector(self, test_bot:KleinanzeigenBot) -> None: """Test parsing ads selector.""" test_bot.parse_args(["script.py", "--ads=all", "publish"]) @@ -931,29 +1062,29 @@ class TestKleinanzeigenBotArgParsing: assert exc_info.value.code == 2 def test_parse_args_explicit_flags(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None: - """Test that explicit flags are set when --config and --logfile options are provided.""" + """Test that explicit flags are set when config/logfile/workspace options are provided.""" config_path = tmp_path / "custom_config.yaml" log_path = tmp_path / "custom.log" - # Test --config flag sets config_explicitly_provided + # Test --config flag stores raw config arg test_bot.parse_args(["script.py", "--config", str(config_path), "help"]) - assert test_bot.config_explicitly_provided is True + assert test_bot._config_arg == str(config_path) assert str(config_path.absolute()) == test_bot.config_file_path - # Reset for next test - test_bot.config_explicitly_provided = False - - # Test --logfile flag sets log_file_explicitly_provided + # Test --logfile flag sets explicit logfile values test_bot.parse_args(["script.py", "--logfile", str(log_path), "help"]) - assert test_bot.log_file_explicitly_provided is True + assert test_bot._logfile_explicitly_provided is True + assert test_bot._logfile_arg == str(log_path) assert str(log_path.absolute()) == test_bot.log_file_path # Test both flags together - test_bot.config_explicitly_provided = False - test_bot.log_file_explicitly_provided = False - test_bot.parse_args(["script.py", "--config", str(config_path), "--logfile", str(log_path), "help"]) - assert test_bot.config_explicitly_provided is True - assert test_bot.log_file_explicitly_provided is True + test_bot._config_arg = None + test_bot._logfile_explicitly_provided = False + test_bot._workspace_mode_arg = None + test_bot.parse_args(["script.py", "--config", str(config_path), "--logfile", str(log_path), "--workspace-mode", "portable", "help"]) + assert test_bot._config_arg == str(config_path) + assert test_bot._logfile_explicitly_provided is True + assert test_bot._workspace_mode_arg == "portable" class TestKleinanzeigenBotCommands: diff --git a/tests/unit/test_update_checker.py b/tests/unit/test_update_checker.py index 7d9817e..16f4587 100644 --- a/tests/unit/test_update_checker.py +++ b/tests/unit/test_update_checker.py @@ -10,13 +10,12 @@ from datetime import datetime, timedelta, timezone, tzinfo from typing import TYPE_CHECKING, Any, cast from unittest.mock import MagicMock, patch -if TYPE_CHECKING: - from pathlib import Path - import pytest import requests if TYPE_CHECKING: + from pathlib import Path + from pytest_mock import MockerFixture from kleinanzeigen_bot.model import update_check_state as update_check_state_module @@ -79,20 +78,20 @@ def state_file(tmp_path:Path) -> Path: class TestUpdateChecker: """Tests for the update checker functionality.""" - def test_get_local_version(self, config:Config) -> None: + def test_get_local_version(self, config:Config, state_file:Path) -> None: """Test that the local version is correctly retrieved.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) assert checker.get_local_version() is not None - def test_get_commit_hash(self, config:Config) -> None: + def test_get_commit_hash(self, config:Config, state_file:Path) -> None: """Test that the commit hash is correctly extracted from the version string.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) assert checker._get_commit_hash("2025+fb00f11") == "fb00f11" assert checker._get_commit_hash("2025") is None - def test_resolve_commitish(self, config:Config) -> None: + def test_resolve_commitish(self, config:Config, state_file:Path) -> None: """Test that a commit-ish is resolved to a full hash and date.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) with patch( "requests.get", return_value = MagicMock(json = lambda: {"sha": "e7a3d46", "commit": {"author": {"date": "2025-05-18T00:00:00Z"}}}) @@ -101,10 +100,10 @@ class TestUpdateChecker: assert commit_hash == "e7a3d46" assert commit_date == datetime(2025, 5, 18, tzinfo = timezone.utc) - def test_request_timeout_uses_config(self, config:Config, mocker:"MockerFixture") -> None: + def test_request_timeout_uses_config(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None: """Ensure HTTP calls honor the timeout configuration.""" config.timeouts.multiplier = 1.5 - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) mock_response = MagicMock(json = lambda: {"sha": "abc", "commit": {"author": {"date": "2025-05-18T00:00:00Z"}}}) mock_get = mocker.patch("requests.get", return_value = mock_response) @@ -113,9 +112,9 @@ class TestUpdateChecker: expected_timeout = config.timeouts.effective("update_check") assert mock_get.call_args.kwargs["timeout"] == expected_timeout - def test_resolve_commitish_no_commit(self, config:Config, mocker:"MockerFixture") -> None: + def test_resolve_commitish_no_commit(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None: """Test resolving a commit-ish when the API returns no commit data.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"sha": "abc"})) commit_hash, commit_date = checker._resolve_commitish("sha") assert commit_hash == "abc" @@ -124,11 +123,12 @@ class TestUpdateChecker: def test_resolve_commitish_logs_warning_on_exception( self, config:Config, + state_file:Path, caplog:pytest.LogCaptureFixture ) -> None: """Test resolving a commit-ish logs a warning when the request fails.""" caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker") - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) with patch("requests.get", side_effect = Exception("boom")): commit_hash, commit_date = checker._resolve_commitish("sha") @@ -136,22 +136,22 @@ class TestUpdateChecker: assert commit_date is None assert any("Could not resolve commit 'sha': boom" in r.getMessage() for r in caplog.records) - def test_commits_match_short_hash(self, config:Config) -> None: + def test_commits_match_short_hash(self, config:Config, state_file:Path) -> None: """Test that short commit hashes are treated as matching prefixes.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) assert checker._commits_match("abc1234", "abc1234def5678") is True - def test_check_for_updates_disabled(self, config:Config) -> None: + def test_check_for_updates_disabled(self, config:Config, state_file:Path) -> None: """Test that the update checker does not check for updates if disabled.""" config.update_check.enabled = False - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) with patch("requests.get") as mock_get: checker.check_for_updates() mock_get.assert_not_called() - def test_check_for_updates_no_local_version(self, config:Config) -> None: + def test_check_for_updates_no_local_version(self, config:Config, state_file:Path) -> None: """Test that the update checker handles the case where the local version cannot be determined.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) with patch.object(UpdateCheckState, "should_check", return_value = True), \ patch.object(UpdateChecker, "get_local_version", return_value = None): checker.check_for_updates() # Should not raise exception @@ -159,38 +159,40 @@ class TestUpdateChecker: def test_check_for_updates_logs_missing_local_version( self, config:Config, + state_file:Path, caplog:pytest.LogCaptureFixture ) -> None: """Test that the update checker logs a warning when the local version is missing.""" caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker") - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) with patch.object(UpdateCheckState, "should_check", return_value = True), \ patch.object(UpdateChecker, "get_local_version", return_value = None): checker.check_for_updates() assert any("Could not determine local version." in r.getMessage() for r in caplog.records) - def test_check_for_updates_no_commit_hash(self, config:Config) -> None: + def test_check_for_updates_no_commit_hash(self, config:Config, state_file:Path) -> None: """Test that the update checker handles the case where the commit hash cannot be extracted.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) with patch.object(UpdateChecker, "get_local_version", return_value = "2025"): checker.check_for_updates() # Should not raise exception - def test_check_for_updates_no_releases(self, config:Config) -> None: + def test_check_for_updates_no_releases(self, config:Config, state_file:Path) -> None: """Test that the update checker handles the case where no releases are found.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) with patch("requests.get", return_value = MagicMock(json = list)): checker.check_for_updates() # Should not raise exception - def test_check_for_updates_api_error(self, config:Config) -> None: + def test_check_for_updates_api_error(self, config:Config, state_file:Path) -> None: """Test that the update checker handles API errors gracefully.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) with patch("requests.get", side_effect = Exception("API Error")): checker.check_for_updates() # Should not raise exception def test_check_for_updates_latest_prerelease_warning( self, config:Config, + state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture ) -> None: @@ -205,13 +207,13 @@ class TestUpdateChecker: return_value = mocker.Mock(json = lambda: {"tag_name": "latest", "prerelease": True}) ) - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) checker.check_for_updates() expected = "Latest release from GitHub is a prerelease, but 'latest' channel expects a stable release." assert any(expected in r.getMessage() for r in caplog.records) - def test_check_for_updates_ahead(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None: + def test_check_for_updates_ahead(self, config:Config, state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None: """Test that the update checker correctly identifies when the local version is ahead of the latest release.""" caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker") mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") @@ -233,7 +235,7 @@ class TestUpdateChecker: ) mocker.patch.object(UpdateCheckState, "should_check", return_value = True) - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) checker.check_for_updates() print("LOG RECORDS:") @@ -246,7 +248,7 @@ class TestUpdateChecker: ) assert any(expected in r.getMessage() for r in caplog.records) - def test_check_for_updates_preview(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None: + def test_check_for_updates_preview(self, config:Config, state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None: """Test that the update checker correctly handles preview releases.""" caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker") config.update_check.channel = "preview" @@ -269,7 +271,7 @@ class TestUpdateChecker: ) mocker.patch.object(UpdateCheckState, "should_check", return_value = True) - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) checker.check_for_updates() print("LOG RECORDS:") @@ -286,6 +288,7 @@ class TestUpdateChecker: def test_check_for_updates_preview_missing_prerelease( self, config:Config, + state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture ) -> None: @@ -301,12 +304,12 @@ class TestUpdateChecker: return_value = mocker.Mock(json = lambda: [{"tag_name": "v1", "prerelease": False, "draft": False}]) ) - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) checker.check_for_updates() assert any("No prerelease found for 'preview' channel." in r.getMessage() for r in caplog.records) - def test_check_for_updates_behind(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None: + def test_check_for_updates_behind(self, config:Config, state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None: """Test that the update checker correctly identifies when the local version is behind the latest release.""" caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker") mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") @@ -328,7 +331,7 @@ class TestUpdateChecker: ) mocker.patch.object(UpdateCheckState, "should_check", return_value = True) - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) checker.check_for_updates() print("LOG RECORDS:") @@ -341,6 +344,7 @@ class TestUpdateChecker: def test_check_for_updates_logs_release_notes( self, config:Config, + state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture ) -> None: @@ -365,12 +369,12 @@ class TestUpdateChecker: ) ) - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) checker.check_for_updates() assert any("Release notes:\nRelease notes here" in r.getMessage() for r in caplog.records) - def test_check_for_updates_same(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None: + def test_check_for_updates_same(self, config:Config, state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None: """Test that the update checker correctly identifies when the local version is the same as the latest release.""" caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker") mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") @@ -392,7 +396,7 @@ class TestUpdateChecker: ) mocker.patch.object(UpdateCheckState, "should_check", return_value = True) - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) checker.check_for_updates() print("LOG RECORDS:") @@ -405,6 +409,7 @@ class TestUpdateChecker: def test_check_for_updates_unknown_channel( self, config:Config, + state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture ) -> None: @@ -416,7 +421,7 @@ class TestUpdateChecker: mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11") mock_get = mocker.patch("requests.get") - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) checker.check_for_updates() mock_get.assert_not_called() @@ -425,6 +430,7 @@ class TestUpdateChecker: def test_check_for_updates_respects_interval_gate( self, config:Config, + state_file:Path, caplog:pytest.LogCaptureFixture ) -> None: """Ensure the interval guard short-circuits update checks without touching the network.""" @@ -433,7 +439,7 @@ class TestUpdateChecker: with patch.object(UpdateCheckState, "should_check", return_value = False) as should_check_mock, \ patch.object(UpdateCheckState, "update_last_check") as update_last_check_mock, \ patch("requests.get") as mock_get: - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) checker.check_for_updates() should_check_mock.assert_called_once() @@ -604,33 +610,33 @@ class TestUpdateChecker: # Should not raise state.save(state_file) - def test_resolve_commitish_no_author(self, config:Config, mocker:"MockerFixture") -> None: + def test_resolve_commitish_no_author(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None: """Test resolving a commit-ish when the API returns no author key.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"sha": "abc", "commit": {}})) commit_hash, commit_date = checker._resolve_commitish("sha") assert commit_hash == "abc" assert commit_date is None - def test_resolve_commitish_no_date(self, config:Config, mocker:"MockerFixture") -> None: + def test_resolve_commitish_no_date(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None: """Test resolving a commit-ish when the API returns no date key.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"sha": "abc", "commit": {"author": {}}})) commit_hash, commit_date = checker._resolve_commitish("sha") assert commit_hash == "abc" assert commit_date is None - def test_resolve_commitish_list_instead_of_dict(self, config:Config, mocker:"MockerFixture") -> None: + def test_resolve_commitish_list_instead_of_dict(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None: """Test resolving a commit-ish when the API returns a list instead of dict.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) mocker.patch("requests.get", return_value = mocker.Mock(json = list)) commit_hash, commit_date = checker._resolve_commitish("sha") assert commit_hash is None assert commit_date is None - def test_check_for_updates_missing_release_commitish(self, config:Config, mocker:"MockerFixture") -> None: + def test_check_for_updates_missing_release_commitish(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None: """Test check_for_updates handles missing release commit-ish.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11") mocker.patch.object(UpdateCheckState, "should_check", return_value = True) @@ -640,21 +646,21 @@ class TestUpdateChecker: ) checker.check_for_updates() # Should not raise - def test_check_for_updates_no_releases_empty(self, config:Config, mocker:"MockerFixture") -> None: + def test_check_for_updates_no_releases_empty(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None: """Test check_for_updates handles no releases found (API returns empty list).""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) mocker.patch("requests.get", return_value = mocker.Mock(json = list)) mocker.patch.object(UpdateCheckState, "should_check", return_value = True) checker.check_for_updates() # Should not raise - def test_check_for_updates_no_commit_hash_extracted(self, config:Config, mocker:"MockerFixture") -> None: + def test_check_for_updates_no_commit_hash_extracted(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None: """Test check_for_updates handles no commit hash extracted.""" - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025") mocker.patch.object(UpdateCheckState, "should_check", return_value = True) checker.check_for_updates() # Should not raise - def test_check_for_updates_no_commit_dates(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None: + def test_check_for_updates_no_commit_dates(self, config:Config, state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None: """Test check_for_updates logs warning if commit dates cannot be determined.""" caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker") mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") @@ -668,7 +674,7 @@ class TestUpdateChecker: json = lambda: {"tag_name": "latest", "prerelease": False} ) ) - checker = UpdateChecker(config) + checker = UpdateChecker(config, state_file) checker.check_for_updates() assert any("Could not determine commit dates for comparison." in r.getMessage() for r in caplog.records) diff --git a/tests/unit/test_web_scraping_mixin.py b/tests/unit/test_web_scraping_mixin.py index 9e7020e..c18c483 100644 --- a/tests/unit/test_web_scraping_mixin.py +++ b/tests/unit/test_web_scraping_mixin.py @@ -987,6 +987,93 @@ class TestWebScrapingBrowserConfiguration: assert "-inprivate" in config.browser_args assert os.environ.get("MSEDGEDRIVER_TELEMETRY_OPTOUT") == "1" + @pytest.mark.asyncio + async def test_create_browser_session_logs_missing_user_data_dir_for_non_test_runs( + self, monkeypatch:pytest.MonkeyPatch, caplog:pytest.LogCaptureFixture + ) -> None: + """Test non-test runtime without user_data_dir logs fallback diagnostics and default profile usage.""" + class DummyConfig: + def __init__(self, **kwargs:object) -> None: + self.browser_args = cast(list[str], kwargs.get("browser_args", [])) + self.user_data_dir = cast(str | None, kwargs.get("user_data_dir")) + self.browser_executable_path = cast(str | None, kwargs.get("browser_executable_path")) + self.headless = cast(bool, kwargs.get("headless", False)) + + def add_extension(self, _ext:str) -> None: + return + + mock_browser = AsyncMock() + mock_browser.websocket_url = "ws://localhost:9222" + monkeypatch.setattr(nodriver, "start", AsyncMock(return_value = mock_browser)) + monkeypatch.setattr("kleinanzeigen_bot.utils.web_scraping_mixin.NodriverConfig", DummyConfig) + monkeypatch.setattr(loggers, "is_debug", lambda _logger: False) + monkeypatch.setattr( + WebScrapingMixin, + "_validate_chrome_version_configuration", + AsyncMock(return_value = None), + ) + + async def mock_exists(path:str | Path) -> bool: + return str(path) == "/usr/bin/chrome" + + monkeypatch.setattr(files, "exists", mock_exists) + caplog.set_level(logging.DEBUG) + + with patch.dict(os.environ, {}, clear = True): + scraper = WebScrapingMixin() + scraper.browser_config.binary_location = "/usr/bin/chrome" + await scraper.create_browser_session() + + cfg = _nodriver_start_mock().call_args[0][0] + assert cfg.user_data_dir is None + assert "--log-level=3" in cfg.browser_args + assert "No browser user_data_dir configured" in caplog.text + assert "No effective browser user_data_dir found" in caplog.text + + @pytest.mark.asyncio + async def test_create_browser_session_ensures_profile_directory_for_user_data_dir(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None: + """Test configured user_data_dir creates profile structure and skips non-debug log-level override.""" + class DummyConfig: + def __init__(self, **kwargs:object) -> None: + self.browser_args = cast(list[str], kwargs.get("browser_args", [])) + self.user_data_dir = cast(str | None, kwargs.get("user_data_dir")) + self.browser_executable_path = cast(str | None, kwargs.get("browser_executable_path")) + self.headless = cast(bool, kwargs.get("headless", False)) + + def add_extension(self, _ext:str) -> None: + return + + mock_browser = AsyncMock() + mock_browser.websocket_url = "ws://localhost:9222" + monkeypatch.setattr(nodriver, "start", AsyncMock(return_value = mock_browser)) + monkeypatch.setattr("kleinanzeigen_bot.utils.web_scraping_mixin.NodriverConfig", DummyConfig) + monkeypatch.setattr(loggers, "is_debug", lambda _logger: True) + monkeypatch.setattr( + WebScrapingMixin, + "_validate_chrome_version_configuration", + AsyncMock(return_value = None), + ) + + async def mock_exists(path:str | Path) -> bool: + path_str = str(path) + if path_str == "/usr/bin/chrome": + return True + return bool(path_str.endswith("Preferences")) + + monkeypatch.setattr(files, "exists", mock_exists) + + with patch.dict(os.environ, {}, clear = True), \ + patch("kleinanzeigen_bot.utils.web_scraping_mixin.xdg_paths.ensure_directory") as mock_ensure_dir: + scraper = WebScrapingMixin() + scraper.browser_config.binary_location = "/usr/bin/chrome" + scraper.browser_config.user_data_dir = str(tmp_path / "profile-root") + await scraper.create_browser_session() + + cfg = _nodriver_start_mock().call_args[0][0] + assert cfg.user_data_dir == str(tmp_path / "profile-root") + assert "--log-level=3" not in cfg.browser_args + mock_ensure_dir.assert_called_once_with(Path(str(tmp_path / "profile-root")), "browser profile directory") + @pytest.mark.asyncio async def test_browser_extension_loading(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None: """Test browser extension loading.""" diff --git a/tests/unit/test_xdg_paths.py b/tests/unit/test_xdg_paths.py index 8fa9d33..cb64b15 100644 --- a/tests/unit/test_xdg_paths.py +++ b/tests/unit/test_xdg_paths.py @@ -1,11 +1,13 @@ -# 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/ -"""Unit tests for XDG paths module.""" +"""Unit tests for workspace/path resolution.""" import io +import re from pathlib import Path +from unittest.mock import patch import pytest @@ -15,10 +17,7 @@ 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)) @@ -27,459 +26,383 @@ class TestGetXdgBaseDir: 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) + monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: None) - with pytest.raises(RuntimeError, match = "Failed to resolve XDG base directory"): + with pytest.raises(RuntimeError, match = "Failed to resolve XDG base directory for category: state"): 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" + assert xdg_paths.detect_installation_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" + assert xdg_paths.detect_installation_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), - ) + monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "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" + assert xdg_paths.detect_installation_mode() is None 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) + def test_returns_portable_for_non_interactive_mode(self, monkeypatch:pytest.MonkeyPatch) -> None: monkeypatch.setattr("sys.stdin", None) - - mode = xdg_paths.prompt_installation_mode() - - assert mode == "portable" + assert xdg_paths.prompt_installation_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 xdg_paths.prompt_installation_mode() == "portable" - 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 + def test_returns_portable_when_user_enters_1(self, monkeypatch:pytest.MonkeyPatch) -> None: 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 + assert xdg_paths.prompt_installation_mode() == "portable" 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 + assert "[2] User directories" 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 + def test_reprompts_on_invalid_input_then_accepts_valid( + self, + monkeypatch:pytest.MonkeyPatch, + capsys:pytest.CaptureFixture[str], + ) -> None: 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"]) + inputs = iter(["invalid", "2"]) monkeypatch.setattr("builtins.input", lambda _: next(inputs)) mode = xdg_paths.prompt_installation_mode() - assert mode == "portable" - # Verify error message was shown + assert mode == "xdg" captured = capsys.readouterr() - assert "Invalid choice" in captured.out + assert "Invalid choice. Please enter 1 or 2." 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 + def test_returns_portable_on_eof_error(self, monkeypatch:pytest.MonkeyPatch) -> None: 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: + def raise_eof(_prompt:str) -> str: raise EOFError - monkeypatch.setattr("builtins.input", mock_input) + monkeypatch.setattr("builtins.input", raise_eof) - mode = xdg_paths.prompt_installation_mode() + assert xdg_paths.prompt_installation_mode() == "portable" - 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 + def test_returns_portable_on_keyboard_interrupt(self, monkeypatch:pytest.MonkeyPatch) -> None: 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: + def raise_keyboard_interrupt(_prompt:str) -> str: raise KeyboardInterrupt - monkeypatch.setattr("builtins.input", mock_input) + monkeypatch.setattr("builtins.input", raise_keyboard_interrupt) - mode = xdg_paths.prompt_installation_mode() - - assert mode == "portable" - # Verify newline was printed after interrupt - captured = capsys.readouterr() - assert captured.out.endswith("\n") + assert xdg_paths.prompt_installation_mode() == "portable" -class TestGetBrowserProfilePathWithOverride: - """Tests for get_browser_profile_path config_override parameter.""" +class TestWorkspace: + def test_ensure_directory_raises_when_target_is_not_directory(self, tmp_path:Path) -> None: + target = tmp_path / "created" - 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.""" + with patch.object(Path, "is_dir", return_value = False), pytest.raises(NotADirectoryError, match = re.escape(str(target))): + xdg_paths.ensure_directory(target, "test directory") + + def test_for_config_derives_portable_layout(self, tmp_path:Path) -> None: + config_file = tmp_path / "custom" / "config.yaml" + ws = xdg_paths.Workspace.for_config(config_file, "mybot") + + assert ws.config_file == config_file.resolve() + assert ws.config_dir == config_file.parent.resolve() + assert ws.log_file == config_file.parent.resolve() / "mybot.log" + assert ws.state_dir == config_file.parent.resolve() / ".temp" + assert ws.download_dir == config_file.parent.resolve() / "downloaded-ads" + assert ws.browser_profile_dir == config_file.parent.resolve() / ".temp" / "browser-profile" + assert ws.diagnostics_dir == config_file.parent.resolve() / ".temp" / "diagnostics" + + def test_resolve_workspace_uses_config_arg(self, tmp_path:Path) -> None: + config_path = tmp_path / "cfg" / "config.yaml" + + ws = xdg_paths.resolve_workspace( + config_arg = str(config_path), + logfile_arg = None, + workspace_mode = "portable", + logfile_explicitly_provided = False, + log_basename = "kleinanzeigen-bot", + ) + + assert ws.config_file == config_path.resolve() + assert ws.log_file == config_path.parent.resolve() / "kleinanzeigen-bot.log" + + def test_resolve_workspace_uses_detected_xdg_layout(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None: + monkeypatch.setattr(xdg_paths, "detect_installation_mode", lambda: "xdg") + monkeypatch.setattr( + xdg_paths, + "get_xdg_base_dir", + lambda category: { + "config": tmp_path / "xdg-config" / xdg_paths.APP_NAME, + "state": tmp_path / "xdg-state" / xdg_paths.APP_NAME, + "cache": tmp_path / "xdg-cache" / xdg_paths.APP_NAME, + }[category], + ) + + ws = xdg_paths.resolve_workspace(None, None, workspace_mode = None, logfile_explicitly_provided = False, log_basename = "kleinanzeigen-bot") + + assert ws.config_file == (tmp_path / "xdg-config" / xdg_paths.APP_NAME / "config.yaml").resolve() + assert ws.log_file == (tmp_path / "xdg-state" / xdg_paths.APP_NAME / "kleinanzeigen-bot.log").resolve() + assert ws.state_dir == (tmp_path / "xdg-state" / xdg_paths.APP_NAME).resolve() + assert ws.browser_profile_dir == (tmp_path / "xdg-cache" / xdg_paths.APP_NAME / "browser-profile").resolve() + assert ws.diagnostics_dir == (tmp_path / "xdg-cache" / xdg_paths.APP_NAME / "diagnostics").resolve() + + def test_resolve_workspace_first_run_uses_prompt_choice(self, monkeypatch:pytest.MonkeyPatch) -> None: + monkeypatch.setattr(xdg_paths, "detect_installation_mode", lambda: None) + monkeypatch.setattr(xdg_paths, "prompt_installation_mode", lambda: "portable") + + ws = xdg_paths.resolve_workspace(None, None, workspace_mode = None, logfile_explicitly_provided = False, log_basename = "kleinanzeigen-bot") + + assert ws.config_file == (Path.cwd() / "config.yaml").resolve() + + def test_resolve_workspace_honors_logfile_override(self, tmp_path:Path) -> None: + config_path = tmp_path / "cfg" / "config.yaml" + explicit_log = tmp_path / "logs" / "my.log" + + ws = xdg_paths.resolve_workspace( + config_arg = str(config_path), + logfile_arg = str(explicit_log), + workspace_mode = "portable", + logfile_explicitly_provided = True, + log_basename = "kleinanzeigen-bot", + ) + + assert ws.log_file == explicit_log.resolve() + + def test_resolve_workspace_disables_logfile_when_empty_flag(self, tmp_path:Path) -> None: + ws = xdg_paths.resolve_workspace( + config_arg = str(tmp_path / "config.yaml"), + logfile_arg = "", + workspace_mode = "portable", + logfile_explicitly_provided = True, + log_basename = "kleinanzeigen-bot", + ) + + assert ws.log_file is None + + def test_resolve_workspace_fails_when_config_mode_is_ambiguous(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None: + config_path = tmp_path / "cfg" / "config.yaml" + config_path.parent.mkdir(parents = True, exist_ok = True) + config_path.touch() + (config_path.parent / ".temp").mkdir(parents = True, exist_ok = True) + + cwd_config = tmp_path / "cwd" / "config.yaml" + cwd_config.parent.mkdir(parents = True, exist_ok = True) + cwd_config.touch() + monkeypatch.chdir(cwd_config.parent) + + monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name)) + monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name)) + monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name)) + (tmp_path / "xdg-config" / xdg_paths.APP_NAME / "config.yaml").parent.mkdir(parents = True, exist_ok = True) + (tmp_path / "xdg-config" / xdg_paths.APP_NAME / "config.yaml").touch() + + with pytest.raises(ValueError, match = "Detected both portable and XDG footprints") as exc_info: + xdg_paths.resolve_workspace( + config_arg = str(config_path), + logfile_arg = None, + workspace_mode = None, + logfile_explicitly_provided = False, + log_basename = "kleinanzeigen-bot", + ) + assert str((config_path.parent / ".temp").resolve()) in str(exc_info.value) + assert str((tmp_path / "xdg-config" / xdg_paths.APP_NAME / "config.yaml").resolve()) in str(exc_info.value) + + def test_resolve_workspace_detects_portable_mode_from_custom_config_footprint(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None: + config_path = tmp_path / "cfg" / "config.yaml" + config_path.parent.mkdir(parents = True, exist_ok = True) + config_path.touch() + (config_path.parent / ".temp").mkdir(parents = True, exist_ok = True) + + monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name)) + monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name)) + monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name)) + + ws = xdg_paths.resolve_workspace( + config_arg = str(config_path), + logfile_arg = None, + workspace_mode = None, + logfile_explicitly_provided = False, + log_basename = "kleinanzeigen-bot", + ) + + assert ws.mode == "portable" + assert ws.config_file == config_path.resolve() + assert ws.state_dir == (config_path.parent / ".temp").resolve() + + def test_resolve_workspace_detects_xdg_mode_from_xdg_footprint(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None: + xdg_config_dir = tmp_path / "xdg-config" / xdg_paths.APP_NAME + xdg_cache_dir = tmp_path / "xdg-cache" / xdg_paths.APP_NAME + xdg_state_dir = tmp_path / "xdg-state" / xdg_paths.APP_NAME + xdg_config_dir.mkdir(parents = True, exist_ok = True) + xdg_cache_dir.mkdir(parents = True, exist_ok = True) + xdg_state_dir.mkdir(parents = True, exist_ok = True) + (xdg_cache_dir / "browser-profile").mkdir(parents = True, exist_ok = True) + (xdg_config_dir / "downloaded-ads").mkdir(parents = True, exist_ok = True) + + monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name)) + monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name)) + monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name)) + + config_path = xdg_config_dir / "config-alt.yaml" + config_path.touch() + + ws = xdg_paths.resolve_workspace( + config_arg = str(config_path), + logfile_arg = None, + workspace_mode = None, + logfile_explicitly_provided = False, + log_basename = "kleinanzeigen-bot", + ) + + assert ws.mode == "xdg" + assert ws.config_file == config_path.resolve() + assert ws.browser_profile_dir == (xdg_cache_dir / "browser-profile").resolve() + + def test_detect_mode_from_footprints_collects_portable_and_xdg_hit_paths(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) + config_path = tmp_path / "config.yaml" + config_path.touch() + (tmp_path / "downloaded-ads").mkdir(parents = True, exist_ok = True) - custom_path = str(tmp_path / "custom" / "browser") - profile_path = xdg_paths.get_browser_profile_path("portable", config_override = custom_path) + xdg_config_dir = tmp_path / "xdg-config" / xdg_paths.APP_NAME + xdg_cache_dir = tmp_path / "xdg-cache" / xdg_paths.APP_NAME + xdg_state_dir = tmp_path / "xdg-state" / xdg_paths.APP_NAME + xdg_config_dir.mkdir(parents = True, exist_ok = True) + xdg_cache_dir.mkdir(parents = True, exist_ok = True) + xdg_state_dir.mkdir(parents = True, exist_ok = True) + (xdg_cache_dir / "diagnostics").mkdir(parents = True, exist_ok = True) + (xdg_state_dir / "update_check_state.json").touch() - assert profile_path == Path(custom_path) - assert profile_path.exists() # Verify directory was created - assert profile_path.is_dir() + monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name)) + monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name)) + monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name)) - 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), + detected_mode, portable_hits, xdg_hits = xdg_paths._detect_mode_from_footprints_with_hits(config_path) # noqa: SLF001 + + assert detected_mode == "ambiguous" + assert config_path.resolve() in portable_hits + assert (tmp_path / "downloaded-ads").resolve() in portable_hits + assert (xdg_cache_dir / "diagnostics").resolve() in xdg_hits + assert (xdg_state_dir / "update_check_state.json").resolve() in xdg_hits + + def test_resolve_workspace_ignores_unrelated_cwd_config_when_config_is_elsewhere(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None: + cwd = tmp_path / "cwd" + cwd.mkdir(parents = True, exist_ok = True) + (cwd / "config.yaml").touch() + monkeypatch.chdir(cwd) + + xdg_config_dir = tmp_path / "xdg-config" / xdg_paths.APP_NAME + xdg_cache_dir = tmp_path / "xdg-cache" / xdg_paths.APP_NAME + xdg_state_dir = tmp_path / "xdg-state" / xdg_paths.APP_NAME + xdg_config_dir.mkdir(parents = True, exist_ok = True) + xdg_cache_dir.mkdir(parents = True, exist_ok = True) + xdg_state_dir.mkdir(parents = True, exist_ok = True) + (xdg_config_dir / "config.yaml").touch() + + monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name)) + monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name)) + monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name)) + + custom_config = tmp_path / "external" / "config.yaml" + custom_config.parent.mkdir(parents = True, exist_ok = True) + custom_config.touch() + + ws = xdg_paths.resolve_workspace( + config_arg = str(custom_config), + logfile_arg = None, + workspace_mode = None, + logfile_explicitly_provided = False, + log_basename = "kleinanzeigen-bot", ) - custom_path = str(tmp_path / "custom" / "browser") - profile_path = xdg_paths.get_browser_profile_path("xdg", config_override = custom_path) + assert ws.mode == "xdg" - 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() + def test_resolve_workspace_fails_when_config_mode_is_unknown(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None: + config_path = tmp_path / "cfg" / "config.yaml" + config_path.parent.mkdir(parents = True, exist_ok = True) + config_path.touch() + (tmp_path / "cwd").mkdir(parents = True, exist_ok = True) + monkeypatch.chdir(tmp_path / "cwd") + monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name)) + monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name)) + monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name)) -class TestUnicodeHandling: - """Tests for Unicode path handling (NFD vs NFC normalization).""" + with pytest.raises(ValueError, match = "Detected neither portable nor XDG footprints") as exc_info: + xdg_paths.resolve_workspace( + config_arg = str(config_path), + logfile_arg = None, + workspace_mode = None, + logfile_explicitly_provided = False, + log_basename = "kleinanzeigen-bot", + ) + assert "Portable footprint hits: none" in str(exc_info.value) + assert "XDG footprint hits: none" in str(exc_info.value) - 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. + def test_resolve_workspace_raises_when_config_path_is_unresolved(self, tmp_path:Path) -> None: + config_path = (tmp_path / "config.yaml").resolve() + original_resolve = Path.resolve - 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) + def patched_resolve(self:Path, strict:bool = False) -> object: + if self == config_path: + return None + return original_resolve(self, strict) - # 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" + with patch.object(Path, "resolve", patched_resolve), pytest.raises( + RuntimeError, match = "Workspace mode and config path must be resolved" + ): + xdg_paths.resolve_workspace( + config_arg = str(config_path), + logfile_arg = None, + workspace_mode = "portable", + logfile_explicitly_provided = False, + log_basename = "kleinanzeigen-bot", + )