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

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

View File

@@ -249,8 +249,9 @@ Options:
* <id(s)>: provide one or several ads by ID to update, like e.g. "--ads=1,2,3" * <id(s)>: provide one or several ads by ID to update, like e.g. "--ads=1,2,3"
--force - alias for '--ads=all' --force - alias for '--ads=all'
--keep-old - don't delete old ads on republication --keep-old - don't delete old ads on republication
--config=<PATH> - path to the config YAML or JSON file (DEFAULT: ./config.yaml) --config=<PATH> - path to the config YAML or JSON file (does not implicitly change workspace mode)
--logfile=<PATH> - path to the logfile (DEFAULT: ./kleinanzeigen-bot.log) --workspace-mode=portable|xdg - overrides workspace mode for this run
--logfile=<PATH> - path to the logfile (DEFAULT: depends on active workspace mode)
--lang=en|de - display language (STANDARD: system language if supported, otherwise English) --lang=en|de - display language (STANDARD: system language if supported, otherwise English)
-v, --verbose - enables verbose output - only useful when troubleshooting issues -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. 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. 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):** 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 - No admin permissions required
- Easy backup/migration; works from USB drives - 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 - Stores files in OS-standard locations
- Cleaner directory structure; better separation from working directory - 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):** **OS notes (brief):**
- **Windows:** System-wide uses AppData (Roaming/Local); portable keeps everything beside the `.exe`. - **Windows:** User directories mode 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. - **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:** System-wide uses `~/Library/Application Support/kleinanzeigen-bot` (and related dirs); portable stays in the current directory. - **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).
### <a name="main-config"></a>1) Main configuration ⚙️ ### <a name="main-config"></a>1) Main configuration ⚙️

View File

@@ -59,12 +59,14 @@ Full documentation for ad YAML files including automatic price reduction, descri
## File Location ## 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 ```bash
kleinanzeigen-bot --config /path/to/config.yaml publish 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` Valid file extensions: `.json`, `.yaml`, `.yml`
## Configuration Structure ## Configuration Structure
@@ -302,15 +304,16 @@ The bot uses a layered approach to detect login state, prioritizing stealth over
**Output locations (default):** **Output locations (default):**
- **Portable mode**: `./.temp/diagnostics/` - **Portable mode + `--config /path/to/config.yaml`**: `/path/to/.temp/diagnostics/` (portable runtime files are placed next to the selected config file)
- **System-wide mode (XDG)**: `~/.cache/kleinanzeigen-bot/diagnostics/` (Linux) or `~/Library/Caches/kleinanzeigen-bot/diagnostics/` (macOS) - **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 - **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. > **⚠️ 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 ## 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):** 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 - No admin permissions required
- Easy backup/migration; works from USB drives - 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 - Stores files in OS-standard locations
- Cleaner directory structure; better separation from working directory - 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:** **OS notes:**
- **Windows:** System-wide uses AppData (Roaming/Local); portable keeps everything beside the `.exe`. - **Windows:** User directories mode 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. - **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:** System-wide uses `~/Library/Application Support/kleinanzeigen-bot` (and related dirs); portable stays in the current directory. - **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 ## Getting Current Defaults

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,8 @@ kleinanzeigen_bot/__init__.py:
"Direct execution not supported. Use 'pdm run app'": "Direkte Ausführung nicht unterstützt. Bitte 'pdm run app' verwenden" "Direct execution not supported. Use 'pdm run app'": "Direkte Ausführung nicht unterstützt. Bitte 'pdm run app' verwenden"
create_default_config: create_default_config:
"Config file %s already exists. Aborting creation.": "Konfigurationsdatei %s existiert bereits. Erstellung abgebrochen." "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: configure_file_logging:
"Logging to [%s]...": "Protokollierung in [%s]..." "Logging to [%s]...": "Protokollierung in [%s]..."
@@ -140,9 +142,15 @@ kleinanzeigen_bot/__init__.py:
find_and_click_extend_button: find_and_click_extend_button:
"Found extend button on page %s": "'Verlängern'-Button auf Seite %s gefunden" "Found extend button on page %s": "'Verlängern'-Button auf Seite %s gefunden"
finalize_installation_mode: _resolve_workspace:
"First run detected, prompting user for installation mode": "Erster Start erkannt, frage Benutzer nach Installationsmodus" "Config: %s": "Konfiguration: %s"
"Installation mode: %s [%s]": "Installationsmodus: %s [%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: publish_ads:
"Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..." "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" "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!" "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: run:
"DONE: No configuration errors found.": "FERTIG: Keine Konfigurationsfehler gefunden." "DONE: No configuration errors found.": "FERTIG: Keine Konfigurationsfehler gefunden."
"DONE: No active ads found.": "FERTIG: Keine aktiven Anzeigen 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: kleinanzeigen_bot/utils/xdg_paths.py:
################################################# #################################################
_ensure_directory: ensure_directory:
"Failed to create %s %s: %s": "Fehler beim Erstellen von %s %s: %s" "Failed to create %s %s: %s": "Fehler beim Erstellen von %s %s: %s"
detect_installation_mode: detect_installation_mode:
"Detected installation mode: %s": "Erkannter Installationsmodus: %s" "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" "Non-interactive mode detected, defaulting to portable installation": "Nicht-interaktiver Modus erkannt, Standard-Installation: portabel"
"Choose installation type:": "Installationstyp wählen:" "Choose installation type:": "Installationstyp wählen:"
"[1] Portable (current directory)": "[1] Portabel (aktuelles Verzeichnis)" "[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: " "Enter 1 or 2: ": "1 oder 2 eingeben: "
"Defaulting to portable installation mode": "Standard-Installationsmodus: portabel" "Defaulting to portable installation mode": "Standard-Installationsmodus: portabel"
"User selected installation mode: %s": "Benutzer hat Installationsmodus gewählt: %s" "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." "Invalid choice. Please enter 1 or 2.": "Ungültige Auswahl. Bitte 1 oder 2 eingeben."
resolve_workspace: {}
_format_hits:
"none": "keine"
_workspace_mode_resolution_error:
? "Cannot determine workspace mode for --config=%(config_file)s. Use --workspace-mode=portable or --workspace-mode=xdg.\nFor cleanup guidance, see: %(url)s"
: "Arbeitsmodus für --config=%(config_file)s konnte nicht bestimmt werden. Verwende --workspace-mode=portable oder --workspace-mode=xdg.\nHinweise zur Bereinigung: %(url)s"
"Portable footprint hits": "Gefundene portable Spuren"
"XDG footprint hits": "Gefundene XDG-Spuren"
"Detected both portable and XDG footprints.": "Sowohl portable als auch XDG-Spuren wurden gefunden."
"Detected neither portable nor XDG footprints.": "Weder portable noch XDG-Spuren wurden gefunden."

View File

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

View File

@@ -152,11 +152,6 @@ class WebScrapingMixin:
self._default_timeout_config:TimeoutConfig | None = None self._default_timeout_config:TimeoutConfig | None = None
self.config:BotConfig = cast(BotConfig, 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: def _get_timeout_config(self) -> TimeoutConfig:
config = getattr(self, "config", None) config = getattr(self, "config", None)
timeouts:TimeoutConfig | None = None timeouts:TimeoutConfig | None = None
@@ -225,7 +220,7 @@ class WebScrapingMixin:
and not has_remote_debugging and not has_remote_debugging
and not is_test_environment 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 # Chrome version detection and validation
if has_remote_debugging: if has_remote_debugging:
@@ -344,7 +339,7 @@ class WebScrapingMixin:
user_data_dir_from_args, user_data_dir_from_args,
) )
if not effective_user_data_dir and not is_test_environment: 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 self.browser_config.user_data_dir = effective_user_data_dir
if not loggers.is_debug(LOG): if not loggers.is_debug(LOG):
@@ -365,6 +360,7 @@ class WebScrapingMixin:
# Enhanced profile directory handling # Enhanced profile directory handling
if cfg.user_data_dir: 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") profile_dir = os.path.join(cfg.user_data_dir, self.browser_config.profile_name or "Default")
os.makedirs(profile_dir, exist_ok = True) os.makedirs(profile_dir, exist_ok = True)
prefs_file = os.path.join(profile_dir, "Preferences") prefs_file = os.path.join(profile_dir, "Preferences")

View File

@@ -1,41 +1,61 @@
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors # SPDX-FileCopyrightText: © Jens Bergmann and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ # SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
"""XDG Base Directory path resolution with backward compatibility. """XDG Base Directory path resolution with workspace abstraction."""
Supports two installation modes:
- Portable: All files in current working directory (for existing installations)
- System-wide: Files organized in XDG directories (for new installations or package managers)
"""
from __future__ import annotations from __future__ import annotations
import sys import sys
from dataclasses import dataclass, replace
from gettext import gettext as _ from gettext import gettext as _
from pathlib import Path from pathlib import Path
from typing import Final, Literal, cast from typing import Final, Literal
import platformdirs import platformdirs
from kleinanzeigen_bot.utils import loggers from kleinanzeigen_bot.utils import loggers
from kleinanzeigen_bot.utils.files import abspath
LOG:Final[loggers.Logger] = loggers.get_logger(__name__) LOG:Final[loggers.Logger] = loggers.get_logger(__name__)
APP_NAME:Final[str] = "kleinanzeigen-bot" APP_NAME:Final[str] = "kleinanzeigen-bot"
InstallationMode = Literal["portable", "xdg"] InstallationMode = Literal["portable", "xdg"]
PathCategory = Literal["config", "cache", "state"] PathCategory = Literal["config", "cache", "state"]
def _normalize_mode(mode:str | InstallationMode) -> InstallationMode: @dataclass(frozen = True)
"""Validate and normalize installation mode input.""" class Workspace:
if mode in {"portable", "xdg"}: """Resolved workspace paths for all bot side effects."""
return cast(InstallationMode, mode)
raise ValueError(f"Unsupported installation mode: {mode}") 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.""" """Create directory and verify it exists."""
LOG.debug("Creating directory: %s", path) LOG.debug("Creating directory: %s", path)
try: try:
@@ -47,15 +67,25 @@ def _ensure_directory(path:Path, description:str) -> None:
raise NotADirectoryError(str(path)) 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: def get_xdg_base_dir(category:PathCategory) -> Path:
"""Get XDG base directory for the given category. """Get XDG base directory for the given category."""
Args:
category: The XDG category (config, cache, or state)
Returns:
Path to the XDG base directory for this app
"""
resolved:str | None = None resolved:str | None = None
match category: match category:
case "config": 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}") raise RuntimeError(f"Failed to resolve XDG base directory for category: {category}")
base_dir = Path(resolved) base_dir = Path(resolved)
LOG.debug("XDG %s directory: %s", category, base_dir) LOG.debug("XDG %s directory: %s", category, base_dir)
return base_dir return base_dir
def detect_installation_mode() -> InstallationMode | None: def detect_installation_mode() -> Literal["portable", "xdg"] | None:
"""Detect installation mode based on config file location. """Detect installation mode based on config file location."""
Returns:
"portable" if ./config.yaml exists in CWD
"xdg" if config exists in XDG location
None if neither exists (first run)
"""
# Check for portable installation (./config.yaml in CWD)
portable_config = Path.cwd() / "config.yaml" portable_config = Path.cwd() / "config.yaml"
LOG.debug("Checking for portable config at: %s", portable_config) 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") LOG.debug("Detected installation mode: %s", "portable")
return "portable" return "portable"
# Check for XDG installation
xdg_config = get_xdg_base_dir("config") / "config.yaml" xdg_config = get_xdg_base_dir("config") / "config.yaml"
LOG.debug("Checking for XDG config at: %s", xdg_config) 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") LOG.debug("Detected installation mode: %s", "xdg")
return "xdg" return "xdg"
# Neither exists - first run
LOG.info("No existing configuration (portable or system-wide) found") LOG.info("No existing configuration (portable or system-wide) found")
return None return None
def prompt_installation_mode() -> InstallationMode: def prompt_installation_mode() -> Literal["portable", "xdg"]:
"""Prompt user to choose installation mode on first run. """Prompt user to choose installation mode on first run."""
Returns:
"portable" or "xdg" based on user choice, or "portable" as default for non-interactive mode
"""
# Check if running in non-interactive mode (no stdin or not a TTY)
if not sys.stdin or not sys.stdin.isatty(): if not sys.stdin or not sys.stdin.isatty():
LOG.info("Non-interactive mode detected, defaulting to portable installation") LOG.info("Non-interactive mode detected, defaulting to portable installation")
return "portable" 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(_("Choose installation type:"))
print(_("[1] Portable (current directory)")) 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: while True:
try: try:
choice = input(_("Enter 1 or 2: ")).strip() choice = input(_("Enter 1 or 2: ")).strip()
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
# Non-interactive or interrupted - default to portable print()
print() # newline after ^C or EOF
LOG.info("Defaulting to portable installation mode") LOG.info("Defaulting to portable installation mode")
return "portable" return "portable"
if choice == "1": if choice == "1":
mode:InstallationMode = "portable" mode:Literal["portable", "xdg"] = "portable"
LOG.info("User selected installation mode: %s", mode) LOG.info("User selected installation mode: %s", mode)
return mode return mode
if choice == "2": if choice == "2":
@@ -140,130 +161,122 @@ def prompt_installation_mode() -> InstallationMode:
print(_("Invalid choice. Please enter 1 or 2.")) print(_("Invalid choice. Please enter 1 or 2."))
def get_config_file_path(mode:str | InstallationMode) -> Path: def _detect_mode_from_footprints_with_hits(
"""Get config.yaml file path for the given mode. config_file:Path,
) -> tuple[Literal["portable", "xdg", "ambiguous", "unknown"], list[Path], list[Path]]:
Args:
mode: Installation mode (portable or xdg)
Returns:
Path to config.yaml
""" """
mode = _normalize_mode(mode) Detect workspace mode and return concrete footprint hits for diagnostics.
config_path = Path.cwd() / "config.yaml" if mode == "portable" else get_xdg_base_dir("config") / "config.yaml"
LOG.debug("Resolving config file path for mode '%s': %s", mode, config_path)
return config_path
def get_ad_files_search_dir(mode:str | InstallationMode) -> Path:
"""Get directory to search for ad files.
Ad files are searched relative to the config file directory,
matching the documented behavior that glob patterns are relative to config.yaml.
Args:
mode: Installation mode (portable or xdg)
Returns:
Path to ad files search directory (same as config file directory)
""" """
mode = _normalize_mode(mode) config_file = config_file.resolve()
search_dir = Path.cwd() if mode == "portable" else get_xdg_base_dir("config") 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) portable_hits:list[Path] = []
return search_dir 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: def _workspace_mode_resolution_error(
"""Get downloaded ads directory path. 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: guidance = _(
mode: Installation mode (portable or xdg) "Cannot determine workspace mode for --config=%(config_file)s. "
"Use --workspace-mode=portable or --workspace-mode=xdg.\n"
Returns: "For cleanup guidance, see: %(url)s"
Path to downloaded ads directory ) % {
"config_file": config_file,
Note: "url": "https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/docs/CONFIGURATION.md#installation-modes",
Creates the directory if it doesn't exist. }
""" details = f"{_format_hits(_('Portable footprint hits'), portable_hits)}\n{_format_hits(_('XDG footprint hits'), xdg_hits)}"
mode = _normalize_mode(mode) if detected_mode == "ambiguous":
ads_path = Path.cwd() / "downloaded-ads" if mode == "portable" else get_xdg_base_dir("config") / "downloaded-ads" 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}")
LOG.debug("Resolving downloaded ads path for mode '%s': %s", mode, ads_path)
# Create directory if it doesn't exist
_ensure_directory(ads_path, "downloaded ads directory")
return ads_path
def get_browser_profile_path(mode:str | InstallationMode, config_override:str | None = None) -> Path: def resolve_workspace(
"""Get browser profile directory path. 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: if config_path and mode is None:
mode: Installation mode (portable or xdg) detected_mode, portable_hits, xdg_hits = _detect_mode_from_footprints_with_hits(config_path)
config_override: Optional config override path (takes precedence) 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: if config_arg:
Path to browser profile directory 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: workspace = Workspace.for_config((Path.cwd() / "config.yaml").resolve(), log_basename) if mode == "portable" else _build_xdg_workspace(log_basename)
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)
# Create directory if it doesn't exist if logfile_explicitly_provided:
_ensure_directory(profile_path, "browser profile directory") workspace = replace(workspace, log_file = Path(abspath(logfile_arg)).resolve() if logfile_arg else None)
return profile_path return workspace
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

View File

@@ -14,7 +14,7 @@ import os
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable, Mapping
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@@ -35,9 +35,19 @@ class CLIResult:
stderr:str 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. 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() stdout = io.StringIO()
stderr = 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(patch("kleinanzeigen_bot.atexit.register", capture_register))
stack.enter_context(contextlib.redirect_stdout(stdout)) stack.enter_context(contextlib.redirect_stdout(stdout))
stack.enter_context(contextlib.redirect_stderr(stderr)) 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: try:
kleinanzeigen_bot.main(["kleinanzeigen-bot", *args]) kleinanzeigen_bot.main(["kleinanzeigen-bot", *args])
except SystemExit as exc: except SystemExit as exc:
@@ -83,6 +96,29 @@ def invoke_cli(args:list[str], cwd:Path | None = None) -> CLIResult:
set_current_locale(previous_locale) 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) @pytest.fixture(autouse = True)
def disable_update_checker(monkeypatch:pytest.MonkeyPatch) -> None: def disable_update_checker(monkeypatch:pytest.MonkeyPatch) -> None:
"""Prevent smoke tests from hitting GitHub for update checks.""" """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) yaml.dump(config_dict, f)
elif serializer is not None: elif serializer is not None:
config_path.write_text(serializer(config_dict), encoding = "utf-8") 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) result = invoke_cli(args, cwd = tmp_path)
assert result.returncode == 0 assert result.returncode == 0
out = (result.stdout + "\n" + result.stderr).lower() out = (result.stdout + "\n" + result.stderr).lower()

View File

@@ -46,7 +46,7 @@ def test_extractor(browser_mock:MagicMock, test_bot_config:Config) -> extract_mo
- browser_mock: Used to mock browser interactions - browser_mock: Used to mock browser interactions
- test_bot_config: Used to initialize the extractor with a valid configuration - 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: class TestAdExtractorBasics:
@@ -54,9 +54,10 @@ class TestAdExtractorBasics:
def test_constructor(self, browser_mock:MagicMock, test_bot_config:Config) -> None: def test_constructor(self, browser_mock:MagicMock, test_bot_config:Config) -> None:
"""Test the constructor of extract_module.AdExtractor""" """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.browser == browser_mock
assert extractor.config == test_bot_config assert extractor.config == test_bot_config
assert extractor.download_dir == Path("downloaded-ads")
@pytest.mark.parametrize( @pytest.mark.parametrize(
("url", "expected_id"), ("url", "expected_id"),
@@ -950,7 +951,7 @@ class TestAdExtractorCategory:
def extractor(self, test_bot_config:Config) -> extract_module.AdExtractor: def extractor(self, test_bot_config:Config) -> extract_module.AdExtractor:
browser_mock = MagicMock(spec = Browser) browser_mock = MagicMock(spec = Browser)
config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}}) 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 @pytest.mark.asyncio
# pylint: disable=protected-access # pylint: disable=protected-access
@@ -1092,7 +1093,7 @@ class TestAdExtractorContact:
def extractor(self, test_bot_config:Config) -> extract_module.AdExtractor: def extractor(self, test_bot_config:Config) -> extract_module.AdExtractor:
browser_mock = MagicMock(spec = Browser) browser_mock = MagicMock(spec = Browser)
config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}}) 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 @pytest.mark.asyncio
# pylint: disable=protected-access # pylint: disable=protected-access
@@ -1163,7 +1164,7 @@ class TestAdExtractorDownload:
def extractor(self, test_bot_config:Config) -> extract_module.AdExtractor: def extractor(self, test_bot_config:Config) -> extract_module.AdExtractor:
browser_mock = MagicMock(spec = Browser) browser_mock = MagicMock(spec = Browser)
config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}}) 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 @pytest.mark.asyncio
async def test_download_ad(self, extractor:extract_module.AdExtractor, tmp_path:Path) -> None: 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" download_base = tmp_path / "downloaded-ads"
final_dir = download_base / "ad_12345_Test Advertisement Title" final_dir = download_base / "ad_12345_Test Advertisement Title"
yaml_path = final_dir / "ad_12345.yaml" yaml_path = final_dir / "ad_12345.yaml"
extractor.download_dir = download_base
with ( 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("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, patch.object(extractor, "_extract_ad_page_info_with_directory_handling", new_callable = AsyncMock) as mock_extract_with_dir,
): ):

View File

@@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ # SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
import copy, fnmatch, io, json, logging, os, tempfile # isort: skip 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 contextlib import redirect_stdout
from datetime import timedelta from datetime import timedelta
from pathlib import Path, PureWindowsPath 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._version import __version__
from kleinanzeigen_bot.model.ad_model import Ad from kleinanzeigen_bot.model.ad_model import Ad
from kleinanzeigen_bot.model.config_model import AdDefaults, Config, DiagnosticsConfig, PublishingConfig 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 from kleinanzeigen_bot.utils.web_scraping_mixin import By, Element
@@ -108,6 +108,26 @@ def mock_config_setup(test_bot:KleinanzeigenBot) -> Generator[None]:
yield 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: class TestKleinanzeigenBotInitialization:
"""Tests for KleinanzeigenBot initialization and basic functionality.""" """Tests for KleinanzeigenBot initialization and basic functionality."""
@@ -126,28 +146,124 @@ class TestKleinanzeigenBotInitialization:
with patch("kleinanzeigen_bot.__version__", "1.2.3"): with patch("kleinanzeigen_bot.__version__", "1.2.3"):
assert test_bot.get_version() == "1.2.3" assert test_bot.get_version() == "1.2.3"
def test_finalize_installation_mode_skips_help(self, test_bot:KleinanzeigenBot) -> None: def test_resolve_workspace_skips_help(self, test_bot:KleinanzeigenBot) -> None:
"""Ensure finalize_installation_mode returns early for help.""" """Ensure workspace resolution returns early for help."""
test_bot.command = "help" test_bot.command = "help"
test_bot.installation_mode = None test_bot.workspace = None
test_bot.finalize_installation_mode() test_bot._resolve_workspace()
assert test_bot.installation_mode is None 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.asyncio
@pytest.mark.parametrize("command", ["verify", "update-check", "update-content-hash", "publish", "delete", "download"]) @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: 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 detected installation mode.""" """Ensure UpdateChecker is initialized with the workspace state file."""
update_checker_calls:list[tuple[Config, str | None]] = [] update_checker_calls:list[tuple[Config, Path]] = []
class DummyUpdateChecker: class DummyUpdateChecker:
def __init__(self, config:Config, installation_mode:str | None) -> None: def __init__(self, config:Config, state_file:Path) -> None:
update_checker_calls.append((config, installation_mode)) update_checker_calls.append((config, state_file))
def check_for_updates(self, *_args:Any, **_kwargs:Any) -> None: def check_for_updates(self, *_args:Any, **_kwargs:Any) -> None:
return None return None
def set_installation_mode() -> None: def set_workspace() -> None:
test_bot.installation_mode = "xdg" test_bot.workspace = xdg_paths.Workspace.for_config(tmp_path / "config.yaml", "kleinanzeigen-bot")
with ( with (
patch.object(test_bot, "configure_file_logging"), 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, "login", new_callable = AsyncMock),
patch.object(test_bot, "download_ads", new_callable = AsyncMock), patch.object(test_bot, "download_ads", new_callable = AsyncMock),
patch.object(test_bot, "close_browser_session"), 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), patch("kleinanzeigen_bot.UpdateChecker", DummyUpdateChecker),
): ):
await test_bot.run(["app", command]) 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 @pytest.mark.asyncio
async def test_download_ads_passes_installation_mode_and_published_ads(self, test_bot:KleinanzeigenBot) -> None: async def test_download_ads_passes_download_dir_and_published_ads(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
"""Ensure download_ads wires installation mode and published_ads_by_id into AdExtractor.""" """Ensure download_ads wires download_dir and published_ads_by_id into AdExtractor."""
test_bot.installation_mode = "xdg" test_bot.workspace = xdg_paths.Workspace.for_config(tmp_path / "config.yaml", "kleinanzeigen-bot")
test_bot.ads_selector = "all" test_bot.ads_selector = "all"
test_bot.browser = MagicMock() test_bot.browser = MagicMock()
@@ -184,7 +301,10 @@ class TestKleinanzeigenBotInitialization:
# Verify published_ads_by_id is built correctly and passed to extractor # Verify published_ads_by_id is built correctly and passed to extractor
mock_extractor.assert_called_once_with( 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_bot.log_file_path is not None
assert "test.log" in test_bot.log_file_path 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: def test_parse_args_ads_selector(self, test_bot:KleinanzeigenBot) -> None:
"""Test parsing ads selector.""" """Test parsing ads selector."""
test_bot.parse_args(["script.py", "--ads=all", "publish"]) test_bot.parse_args(["script.py", "--ads=all", "publish"])
@@ -931,29 +1062,29 @@ class TestKleinanzeigenBotArgParsing:
assert exc_info.value.code == 2 assert exc_info.value.code == 2
def test_parse_args_explicit_flags(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None: 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" config_path = tmp_path / "custom_config.yaml"
log_path = tmp_path / "custom.log" 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"]) 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 assert str(config_path.absolute()) == test_bot.config_file_path
# Reset for next test # Test --logfile flag sets explicit logfile values
test_bot.config_explicitly_provided = False
# Test --logfile flag sets log_file_explicitly_provided
test_bot.parse_args(["script.py", "--logfile", str(log_path), "help"]) 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 assert str(log_path.absolute()) == test_bot.log_file_path
# Test both flags together # Test both flags together
test_bot.config_explicitly_provided = False test_bot._config_arg = None
test_bot.log_file_explicitly_provided = False test_bot._logfile_explicitly_provided = False
test_bot.parse_args(["script.py", "--config", str(config_path), "--logfile", str(log_path), "help"]) test_bot._workspace_mode_arg = None
assert test_bot.config_explicitly_provided is True test_bot.parse_args(["script.py", "--config", str(config_path), "--logfile", str(log_path), "--workspace-mode", "portable", "help"])
assert test_bot.log_file_explicitly_provided is True 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: class TestKleinanzeigenBotCommands:

View File

@@ -10,13 +10,12 @@ from datetime import datetime, timedelta, timezone, tzinfo
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
if TYPE_CHECKING:
from pathlib import Path
import pytest import pytest
import requests import requests
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from kleinanzeigen_bot.model import update_check_state as update_check_state_module 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: class TestUpdateChecker:
"""Tests for the update checker functionality.""" """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.""" """Test that the local version is correctly retrieved."""
checker = UpdateChecker(config) checker = UpdateChecker(config, state_file)
assert checker.get_local_version() is not None 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.""" """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+fb00f11") == "fb00f11"
assert checker._get_commit_hash("2025") is None 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.""" """Test that a commit-ish is resolved to a full hash and date."""
checker = UpdateChecker(config) checker = UpdateChecker(config, state_file)
with patch( with patch(
"requests.get", "requests.get",
return_value = MagicMock(json = lambda: {"sha": "e7a3d46", "commit": {"author": {"date": "2025-05-18T00:00:00Z"}}}) 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_hash == "e7a3d46"
assert commit_date == datetime(2025, 5, 18, tzinfo = timezone.utc) 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.""" """Ensure HTTP calls honor the timeout configuration."""
config.timeouts.multiplier = 1.5 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_response = MagicMock(json = lambda: {"sha": "abc", "commit": {"author": {"date": "2025-05-18T00:00:00Z"}}})
mock_get = mocker.patch("requests.get", return_value = mock_response) mock_get = mocker.patch("requests.get", return_value = mock_response)
@@ -113,9 +112,9 @@ class TestUpdateChecker:
expected_timeout = config.timeouts.effective("update_check") expected_timeout = config.timeouts.effective("update_check")
assert mock_get.call_args.kwargs["timeout"] == expected_timeout 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.""" """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"})) mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"sha": "abc"}))
commit_hash, commit_date = checker._resolve_commitish("sha") commit_hash, commit_date = checker._resolve_commitish("sha")
assert commit_hash == "abc" assert commit_hash == "abc"
@@ -124,11 +123,12 @@ class TestUpdateChecker:
def test_resolve_commitish_logs_warning_on_exception( def test_resolve_commitish_logs_warning_on_exception(
self, self,
config:Config, config:Config,
state_file:Path,
caplog:pytest.LogCaptureFixture caplog:pytest.LogCaptureFixture
) -> None: ) -> None:
"""Test resolving a commit-ish logs a warning when the request fails.""" """Test resolving a commit-ish logs a warning when the request fails."""
caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker") 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")): with patch("requests.get", side_effect = Exception("boom")):
commit_hash, commit_date = checker._resolve_commitish("sha") commit_hash, commit_date = checker._resolve_commitish("sha")
@@ -136,22 +136,22 @@ class TestUpdateChecker:
assert commit_date is None assert commit_date is None
assert any("Could not resolve commit 'sha': boom" in r.getMessage() for r in caplog.records) 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.""" """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 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.""" """Test that the update checker does not check for updates if disabled."""
config.update_check.enabled = False config.update_check.enabled = False
checker = UpdateChecker(config) checker = UpdateChecker(config, state_file)
with patch("requests.get") as mock_get: with patch("requests.get") as mock_get:
checker.check_for_updates() checker.check_for_updates()
mock_get.assert_not_called() 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.""" """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), \ with patch.object(UpdateCheckState, "should_check", return_value = True), \
patch.object(UpdateChecker, "get_local_version", return_value = None): patch.object(UpdateChecker, "get_local_version", return_value = None):
checker.check_for_updates() # Should not raise exception checker.check_for_updates() # Should not raise exception
@@ -159,38 +159,40 @@ class TestUpdateChecker:
def test_check_for_updates_logs_missing_local_version( def test_check_for_updates_logs_missing_local_version(
self, self,
config:Config, config:Config,
state_file:Path,
caplog:pytest.LogCaptureFixture caplog:pytest.LogCaptureFixture
) -> None: ) -> None:
"""Test that the update checker logs a warning when the local version is missing.""" """Test that the update checker logs a warning when the local version is missing."""
caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker") 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), \ with patch.object(UpdateCheckState, "should_check", return_value = True), \
patch.object(UpdateChecker, "get_local_version", return_value = None): patch.object(UpdateChecker, "get_local_version", return_value = None):
checker.check_for_updates() checker.check_for_updates()
assert any("Could not determine local version." in r.getMessage() for r in caplog.records) 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.""" """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"): with patch.object(UpdateChecker, "get_local_version", return_value = "2025"):
checker.check_for_updates() # Should not raise exception 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.""" """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)): with patch("requests.get", return_value = MagicMock(json = list)):
checker.check_for_updates() # Should not raise exception 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.""" """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")): with patch("requests.get", side_effect = Exception("API Error")):
checker.check_for_updates() # Should not raise exception checker.check_for_updates() # Should not raise exception
def test_check_for_updates_latest_prerelease_warning( def test_check_for_updates_latest_prerelease_warning(
self, self,
config:Config, config:Config,
state_file:Path,
mocker:"MockerFixture", mocker:"MockerFixture",
caplog:pytest.LogCaptureFixture caplog:pytest.LogCaptureFixture
) -> None: ) -> None:
@@ -205,13 +207,13 @@ class TestUpdateChecker:
return_value = mocker.Mock(json = lambda: {"tag_name": "latest", "prerelease": True}) return_value = mocker.Mock(json = lambda: {"tag_name": "latest", "prerelease": True})
) )
checker = UpdateChecker(config) checker = UpdateChecker(config, state_file)
checker.check_for_updates() checker.check_for_updates()
expected = "Latest release from GitHub is a prerelease, but 'latest' channel expects a stable release." 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) 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.""" """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") caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker")
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") 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) mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
checker = UpdateChecker(config) checker = UpdateChecker(config, state_file)
checker.check_for_updates() checker.check_for_updates()
print("LOG RECORDS:") print("LOG RECORDS:")
@@ -246,7 +248,7 @@ class TestUpdateChecker:
) )
assert any(expected in r.getMessage() for r in caplog.records) 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.""" """Test that the update checker correctly handles preview releases."""
caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker") caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker")
config.update_check.channel = "preview" config.update_check.channel = "preview"
@@ -269,7 +271,7 @@ class TestUpdateChecker:
) )
mocker.patch.object(UpdateCheckState, "should_check", return_value = True) mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
checker = UpdateChecker(config) checker = UpdateChecker(config, state_file)
checker.check_for_updates() checker.check_for_updates()
print("LOG RECORDS:") print("LOG RECORDS:")
@@ -286,6 +288,7 @@ class TestUpdateChecker:
def test_check_for_updates_preview_missing_prerelease( def test_check_for_updates_preview_missing_prerelease(
self, self,
config:Config, config:Config,
state_file:Path,
mocker:"MockerFixture", mocker:"MockerFixture",
caplog:pytest.LogCaptureFixture caplog:pytest.LogCaptureFixture
) -> None: ) -> None:
@@ -301,12 +304,12 @@ class TestUpdateChecker:
return_value = mocker.Mock(json = lambda: [{"tag_name": "v1", "prerelease": False, "draft": False}]) 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() checker.check_for_updates()
assert any("No prerelease found for 'preview' channel." in r.getMessage() for r in caplog.records) 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.""" """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") caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker")
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") 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) mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
checker = UpdateChecker(config) checker = UpdateChecker(config, state_file)
checker.check_for_updates() checker.check_for_updates()
print("LOG RECORDS:") print("LOG RECORDS:")
@@ -341,6 +344,7 @@ class TestUpdateChecker:
def test_check_for_updates_logs_release_notes( def test_check_for_updates_logs_release_notes(
self, self,
config:Config, config:Config,
state_file:Path,
mocker:"MockerFixture", mocker:"MockerFixture",
caplog:pytest.LogCaptureFixture caplog:pytest.LogCaptureFixture
) -> None: ) -> None:
@@ -365,12 +369,12 @@ class TestUpdateChecker:
) )
) )
checker = UpdateChecker(config) checker = UpdateChecker(config, state_file)
checker.check_for_updates() checker.check_for_updates()
assert any("Release notes:\nRelease notes here" in r.getMessage() for r in caplog.records) 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.""" """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") caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker")
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") 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) mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
checker = UpdateChecker(config) checker = UpdateChecker(config, state_file)
checker.check_for_updates() checker.check_for_updates()
print("LOG RECORDS:") print("LOG RECORDS:")
@@ -405,6 +409,7 @@ class TestUpdateChecker:
def test_check_for_updates_unknown_channel( def test_check_for_updates_unknown_channel(
self, self,
config:Config, config:Config,
state_file:Path,
mocker:"MockerFixture", mocker:"MockerFixture",
caplog:pytest.LogCaptureFixture caplog:pytest.LogCaptureFixture
) -> None: ) -> None:
@@ -416,7 +421,7 @@ class TestUpdateChecker:
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11") mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
mock_get = mocker.patch("requests.get") mock_get = mocker.patch("requests.get")
checker = UpdateChecker(config) checker = UpdateChecker(config, state_file)
checker.check_for_updates() checker.check_for_updates()
mock_get.assert_not_called() mock_get.assert_not_called()
@@ -425,6 +430,7 @@ class TestUpdateChecker:
def test_check_for_updates_respects_interval_gate( def test_check_for_updates_respects_interval_gate(
self, self,
config:Config, config:Config,
state_file:Path,
caplog:pytest.LogCaptureFixture caplog:pytest.LogCaptureFixture
) -> None: ) -> None:
"""Ensure the interval guard short-circuits update checks without touching the network.""" """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, \ 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.object(UpdateCheckState, "update_last_check") as update_last_check_mock, \
patch("requests.get") as mock_get: patch("requests.get") as mock_get:
checker = UpdateChecker(config) checker = UpdateChecker(config, state_file)
checker.check_for_updates() checker.check_for_updates()
should_check_mock.assert_called_once() should_check_mock.assert_called_once()
@@ -604,33 +610,33 @@ class TestUpdateChecker:
# Should not raise # Should not raise
state.save(state_file) 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.""" """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": {}})) mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"sha": "abc", "commit": {}}))
commit_hash, commit_date = checker._resolve_commitish("sha") commit_hash, commit_date = checker._resolve_commitish("sha")
assert commit_hash == "abc" assert commit_hash == "abc"
assert commit_date is None 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.""" """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": {}}})) mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"sha": "abc", "commit": {"author": {}}}))
commit_hash, commit_date = checker._resolve_commitish("sha") commit_hash, commit_date = checker._resolve_commitish("sha")
assert commit_hash == "abc" assert commit_hash == "abc"
assert commit_date is None 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.""" """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)) mocker.patch("requests.get", return_value = mocker.Mock(json = list))
commit_hash, commit_date = checker._resolve_commitish("sha") commit_hash, commit_date = checker._resolve_commitish("sha")
assert commit_hash is None assert commit_hash is None
assert commit_date 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.""" """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_local_version", return_value = "2025+fb00f11")
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11") mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
mocker.patch.object(UpdateCheckState, "should_check", return_value = True) mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
@@ -640,21 +646,21 @@ class TestUpdateChecker:
) )
checker.check_for_updates() # Should not raise 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).""" """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("requests.get", return_value = mocker.Mock(json = list))
mocker.patch.object(UpdateCheckState, "should_check", return_value = True) mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
checker.check_for_updates() # Should not raise 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.""" """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(UpdateChecker, "get_local_version", return_value = "2025")
mocker.patch.object(UpdateCheckState, "should_check", return_value = True) mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
checker.check_for_updates() # Should not raise 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.""" """Test check_for_updates logs warning if commit dates cannot be determined."""
caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker") caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker")
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
@@ -668,7 +674,7 @@ class TestUpdateChecker:
json = lambda: {"tag_name": "latest", "prerelease": False} json = lambda: {"tag_name": "latest", "prerelease": False}
) )
) )
checker = UpdateChecker(config) checker = UpdateChecker(config, state_file)
checker.check_for_updates() checker.check_for_updates()
assert any("Could not determine commit dates for comparison." in r.getMessage() for r in caplog.records) assert any("Could not determine commit dates for comparison." in r.getMessage() for r in caplog.records)

View File

@@ -987,6 +987,93 @@ class TestWebScrapingBrowserConfiguration:
assert "-inprivate" in config.browser_args assert "-inprivate" in config.browser_args
assert os.environ.get("MSEDGEDRIVER_TELEMETRY_OPTOUT") == "1" 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 @pytest.mark.asyncio
async def test_browser_extension_loading(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None: async def test_browser_extension_loading(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test browser extension loading.""" """Test browser extension loading."""

View File

@@ -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-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ # 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 io
import re
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import pytest import pytest
@@ -15,10 +17,7 @@ pytestmark = pytest.mark.unit
class TestGetXdgBaseDir: class TestGetXdgBaseDir:
"""Tests for get_xdg_base_dir function."""
def test_returns_state_dir(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None: def test_returns_state_dir(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test resolving XDG state directory."""
state_dir = tmp_path / "state" state_dir = tmp_path / "state"
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(state_dir / app_name)) 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" assert resolved == state_dir / "kleinanzeigen-bot"
def test_raises_for_unknown_category(self) -> None: def test_raises_for_unknown_category(self) -> None:
"""Test invalid category handling."""
with pytest.raises(ValueError, match = "Unsupported XDG category"): with pytest.raises(ValueError, match = "Unsupported XDG category"):
xdg_paths.get_xdg_base_dir("invalid") # type: ignore[arg-type] xdg_paths.get_xdg_base_dir("invalid") # type: ignore[arg-type]
def test_raises_when_base_dir_is_none(self, monkeypatch:pytest.MonkeyPatch) -> None: 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") xdg_paths.get_xdg_base_dir("state")
class TestDetectInstallationMode: 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: 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) monkeypatch.chdir(tmp_path)
(tmp_path / "config.yaml").touch() (tmp_path / "config.yaml").touch()
# Execute assert xdg_paths.detect_installation_mode() == "portable"
mode = xdg_paths.detect_installation_mode()
# Verify
assert mode == "portable"
def test_detects_xdg_mode_when_config_exists_in_xdg_location(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None: 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 = tmp_path / "config" / "kleinanzeigen-bot"
xdg_config.mkdir(parents = True) xdg_config.mkdir(parents = True)
(xdg_config / "config.yaml").touch() (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)) 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 = tmp_path / "cwd"
cwd.mkdir() cwd.mkdir()
monkeypatch.chdir(cwd) monkeypatch.chdir(cwd)
# Execute assert xdg_paths.detect_installation_mode() == "xdg"
mode = xdg_paths.detect_installation_mode()
# Verify
assert mode == "xdg"
def test_returns_none_when_no_config_found(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None: 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) 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 / "xdg" / app_name))
monkeypatch.setattr(
"platformdirs.user_config_dir",
lambda app_name, *args, **kwargs: str(tmp_path / "nonexistent-xdg" / app_name),
)
# Execute assert xdg_paths.detect_installation_mode() is None
mode = xdg_paths.detect_installation_mode()
# Verify
assert mode is None
class TestGetConfigFilePath:
"""Tests for get_config_file_path function."""
def test_returns_cwd_path_in_portable_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that portable mode returns ./config.yaml."""
monkeypatch.chdir(tmp_path)
path = xdg_paths.get_config_file_path("portable")
assert path == tmp_path / "config.yaml"
def test_returns_xdg_path_in_xdg_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that XDG mode returns XDG config path."""
xdg_config = tmp_path / "config"
monkeypatch.setattr(
"platformdirs.user_config_dir",
lambda app_name, *args, **kwargs: str(xdg_config / app_name),
)
path = xdg_paths.get_config_file_path("xdg")
assert "kleinanzeigen-bot" in str(path)
assert path.name == "config.yaml"
class TestGetAdFilesSearchDir:
"""Tests for get_ad_files_search_dir function."""
def test_returns_cwd_in_portable_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that portable mode searches in CWD."""
monkeypatch.chdir(tmp_path)
search_dir = xdg_paths.get_ad_files_search_dir("portable")
assert search_dir == tmp_path
def test_returns_xdg_config_dir_in_xdg_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that XDG mode searches in XDG config directory (same as config file)."""
xdg_config = tmp_path / "config"
monkeypatch.setattr(
"platformdirs.user_config_dir",
lambda app_name, *args, **kwargs: str(xdg_config / app_name),
)
search_dir = xdg_paths.get_ad_files_search_dir("xdg")
assert "kleinanzeigen-bot" in str(search_dir)
# Ad files searched in same directory as config file, not separate ads/ subdirectory
assert search_dir.name == "kleinanzeigen-bot"
class TestGetDownloadedAdsPath:
"""Tests for get_downloaded_ads_path function."""
def test_returns_cwd_downloaded_ads_in_portable_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that portable mode uses ./downloaded-ads/."""
monkeypatch.chdir(tmp_path)
ads_path = xdg_paths.get_downloaded_ads_path("portable")
assert ads_path == tmp_path / "downloaded-ads"
def test_creates_directory_if_not_exists(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that directory is created if it doesn't exist."""
monkeypatch.chdir(tmp_path)
ads_path = xdg_paths.get_downloaded_ads_path("portable")
assert ads_path.exists()
assert ads_path.is_dir()
def test_returns_xdg_downloaded_ads_in_xdg_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that XDG mode uses XDG config/downloaded-ads/."""
xdg_config = tmp_path / "config"
monkeypatch.setattr(
"platformdirs.user_config_dir",
lambda app_name, *args, **kwargs: str(xdg_config / app_name),
)
ads_path = xdg_paths.get_downloaded_ads_path("xdg")
assert "kleinanzeigen-bot" in str(ads_path)
assert ads_path.name == "downloaded-ads"
class TestGetBrowserProfilePath:
"""Tests for get_browser_profile_path function."""
def test_returns_cwd_temp_in_portable_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that portable mode uses ./.temp/browser-profile."""
monkeypatch.chdir(tmp_path)
profile_path = xdg_paths.get_browser_profile_path("portable")
assert profile_path == tmp_path / ".temp" / "browser-profile"
def test_returns_xdg_cache_in_xdg_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that XDG mode uses XDG cache directory."""
xdg_cache = tmp_path / "cache"
monkeypatch.setattr(
"platformdirs.user_cache_dir",
lambda app_name, *args, **kwargs: str(xdg_cache / app_name),
)
profile_path = xdg_paths.get_browser_profile_path("xdg")
assert "kleinanzeigen-bot" in str(profile_path)
assert profile_path.name == "browser-profile"
def test_creates_directory_if_not_exists(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that browser profile directory is created."""
monkeypatch.chdir(tmp_path)
profile_path = xdg_paths.get_browser_profile_path("portable")
assert profile_path.exists()
assert profile_path.is_dir()
class TestGetLogFilePath:
"""Tests for get_log_file_path function."""
def test_returns_cwd_log_in_portable_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that portable mode uses ./{basename}.log."""
monkeypatch.chdir(tmp_path)
log_path = xdg_paths.get_log_file_path("test", "portable")
assert log_path == tmp_path / "test.log"
def test_returns_xdg_state_log_in_xdg_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that XDG mode uses XDG state directory."""
xdg_state = tmp_path / "state"
monkeypatch.setattr(
"platformdirs.user_state_dir",
lambda app_name, *args, **kwargs: str(xdg_state / app_name),
)
log_path = xdg_paths.get_log_file_path("test", "xdg")
assert "kleinanzeigen-bot" in str(log_path)
assert log_path.name == "test.log"
class TestGetUpdateCheckStatePath:
"""Tests for get_update_check_state_path function."""
def test_returns_cwd_temp_in_portable_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that portable mode uses ./.temp/update_check_state.json."""
monkeypatch.chdir(tmp_path)
state_path = xdg_paths.get_update_check_state_path("portable")
assert state_path == tmp_path / ".temp" / "update_check_state.json"
def test_returns_xdg_state_in_xdg_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that XDG mode uses XDG state directory."""
xdg_state = tmp_path / "state"
monkeypatch.setattr(
"platformdirs.user_state_dir",
lambda app_name, *args, **kwargs: str(xdg_state / app_name),
)
state_path = xdg_paths.get_update_check_state_path("xdg")
assert "kleinanzeigen-bot" in str(state_path)
assert state_path.name == "update_check_state.json"
class TestPromptInstallationMode: class TestPromptInstallationMode:
"""Tests for prompt_installation_mode function."""
@pytest.fixture(autouse = True) @pytest.fixture(autouse = True)
def _force_identity_translation(self, monkeypatch:pytest.MonkeyPatch) -> None: def _force_identity_translation(self, monkeypatch:pytest.MonkeyPatch) -> None:
"""Ensure prompt strings are stable regardless of locale."""
monkeypatch.setattr(xdg_paths, "_", lambda message: message) monkeypatch.setattr(xdg_paths, "_", lambda message: message)
def test_returns_portable_for_non_interactive_mode_no_stdin(self, monkeypatch:pytest.MonkeyPatch) -> None: def test_returns_portable_for_non_interactive_mode(self, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that non-interactive mode (no stdin) defaults to portable."""
# Mock sys.stdin to be None (simulates non-interactive environment)
monkeypatch.setattr("sys.stdin", None) monkeypatch.setattr("sys.stdin", None)
assert xdg_paths.prompt_installation_mode() == "portable"
mode = xdg_paths.prompt_installation_mode()
assert mode == "portable"
def test_returns_portable_for_non_interactive_mode_not_tty(self, monkeypatch:pytest.MonkeyPatch) -> None: 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 = io.StringIO()
mock_stdin.isatty = lambda: False # type: ignore[method-assign] mock_stdin.isatty = lambda: False # type: ignore[method-assign]
monkeypatch.setattr("sys.stdin", mock_stdin) 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) -> None:
def test_returns_portable_when_user_enters_1(self, monkeypatch:pytest.MonkeyPatch, capsys:pytest.CaptureFixture[str]) -> None:
"""Test that user entering '1' selects portable mode."""
# Mock sys.stdin to simulate interactive terminal
mock_stdin = io.StringIO() mock_stdin = io.StringIO()
mock_stdin.isatty = lambda: True # type: ignore[method-assign] mock_stdin.isatty = lambda: True # type: ignore[method-assign]
monkeypatch.setattr("sys.stdin", mock_stdin) monkeypatch.setattr("sys.stdin", mock_stdin)
# Mock interactive input
monkeypatch.setattr("builtins.input", lambda _: "1") monkeypatch.setattr("builtins.input", lambda _: "1")
mode = xdg_paths.prompt_installation_mode() assert xdg_paths.prompt_installation_mode() == "portable"
assert mode == "portable"
# Verify prompt was shown
captured = capsys.readouterr()
assert "Choose installation type:" in captured.out
assert "[1] Portable" in captured.out
def test_returns_xdg_when_user_enters_2(self, monkeypatch:pytest.MonkeyPatch, capsys:pytest.CaptureFixture[str]) -> None: 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 = io.StringIO()
mock_stdin.isatty = lambda: True # type: ignore[method-assign] mock_stdin.isatty = lambda: True # type: ignore[method-assign]
monkeypatch.setattr("sys.stdin", mock_stdin) monkeypatch.setattr("sys.stdin", mock_stdin)
# Mock interactive input
monkeypatch.setattr("builtins.input", lambda _: "2") monkeypatch.setattr("builtins.input", lambda _: "2")
mode = xdg_paths.prompt_installation_mode() mode = xdg_paths.prompt_installation_mode()
assert mode == "xdg" assert mode == "xdg"
# Verify prompt was shown
captured = capsys.readouterr() captured = capsys.readouterr()
assert "Choose installation type:" in captured.out 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: def test_reprompts_on_invalid_input_then_accepts_valid(
"""Test that invalid input causes re-prompt, then valid input is accepted.""" self,
# Mock sys.stdin to simulate interactive terminal monkeypatch:pytest.MonkeyPatch,
capsys:pytest.CaptureFixture[str],
) -> None:
mock_stdin = io.StringIO() mock_stdin = io.StringIO()
mock_stdin.isatty = lambda: True # type: ignore[method-assign] mock_stdin.isatty = lambda: True # type: ignore[method-assign]
monkeypatch.setattr("sys.stdin", mock_stdin) monkeypatch.setattr("sys.stdin", mock_stdin)
inputs = iter(["invalid", "2"])
# Mock sequence of inputs: invalid, then valid
inputs = iter(["3", "invalid", "1"])
monkeypatch.setattr("builtins.input", lambda _: next(inputs)) monkeypatch.setattr("builtins.input", lambda _: next(inputs))
mode = xdg_paths.prompt_installation_mode() mode = xdg_paths.prompt_installation_mode()
assert mode == "portable" assert mode == "xdg"
# Verify error message was shown
captured = capsys.readouterr() 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: def test_returns_portable_on_eof_error(self, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that EOFError (Ctrl+D) defaults to portable mode."""
# Mock sys.stdin to simulate interactive terminal
mock_stdin = io.StringIO() mock_stdin = io.StringIO()
mock_stdin.isatty = lambda: True # type: ignore[method-assign] mock_stdin.isatty = lambda: True # type: ignore[method-assign]
monkeypatch.setattr("sys.stdin", mock_stdin) monkeypatch.setattr("sys.stdin", mock_stdin)
# Mock input raising EOFError def raise_eof(_prompt:str) -> str:
def mock_input(_:str) -> str:
raise EOFError 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" def test_returns_portable_on_keyboard_interrupt(self, monkeypatch:pytest.MonkeyPatch) -> None:
# Verify newline was printed after EOF
captured = capsys.readouterr()
assert captured.out.endswith("\n")
def test_returns_portable_on_keyboard_interrupt(self, monkeypatch:pytest.MonkeyPatch, capsys:pytest.CaptureFixture[str]) -> None:
"""Test that KeyboardInterrupt (Ctrl+C) defaults to portable mode."""
# Mock sys.stdin to simulate interactive terminal
mock_stdin = io.StringIO() mock_stdin = io.StringIO()
mock_stdin.isatty = lambda: True # type: ignore[method-assign] mock_stdin.isatty = lambda: True # type: ignore[method-assign]
monkeypatch.setattr("sys.stdin", mock_stdin) monkeypatch.setattr("sys.stdin", mock_stdin)
# Mock input raising KeyboardInterrupt def raise_keyboard_interrupt(_prompt:str) -> str:
def mock_input(_:str) -> str:
raise KeyboardInterrupt raise KeyboardInterrupt
monkeypatch.setattr("builtins.input", mock_input) monkeypatch.setattr("builtins.input", raise_keyboard_interrupt)
mode = xdg_paths.prompt_installation_mode() assert xdg_paths.prompt_installation_mode() == "portable"
assert mode == "portable"
# Verify newline was printed after interrupt
captured = capsys.readouterr()
assert captured.out.endswith("\n")
class TestGetBrowserProfilePathWithOverride: class TestWorkspace:
"""Tests for get_browser_profile_path config_override parameter.""" 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: with patch.object(Path, "is_dir", return_value = False), pytest.raises(NotADirectoryError, match = re.escape(str(target))):
"""Test that config_override takes precedence in portable mode.""" 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) 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") xdg_config_dir = tmp_path / "xdg-config" / xdg_paths.APP_NAME
profile_path = xdg_paths.get_browser_profile_path("portable", config_override = custom_path) 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) monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name))
assert profile_path.exists() # Verify directory was created monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name))
assert profile_path.is_dir() 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: detected_mode, portable_hits, xdg_hits = xdg_paths._detect_mode_from_footprints_with_hits(config_path) # noqa: SLF001
"""Test that config_override takes precedence in XDG mode."""
xdg_cache = tmp_path / "cache" assert detected_mode == "ambiguous"
monkeypatch.setattr( assert config_path.resolve() in portable_hits
"platformdirs.user_cache_dir", assert (tmp_path / "downloaded-ads").resolve() in portable_hits
lambda app_name, *args, **kwargs: str(xdg_cache / app_name), 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") assert ws.mode == "xdg"
profile_path = xdg_paths.get_browser_profile_path("xdg", config_override = custom_path)
assert profile_path == Path(custom_path) def test_resolve_workspace_fails_when_config_mode_is_unknown(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
# Verify it didn't use XDG cache directory config_path = tmp_path / "cfg" / "config.yaml"
assert str(profile_path) != str(xdg_cache / "kleinanzeigen-bot" / "browser-profile") config_path.parent.mkdir(parents = True, exist_ok = True)
assert profile_path.exists() config_path.touch()
assert profile_path.is_dir() (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: with pytest.raises(ValueError, match = "Detected neither portable nor XDG footprints") as exc_info:
"""Tests for Unicode path handling (NFD vs NFC normalization).""" 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: def test_resolve_workspace_raises_when_config_path_is_unresolved(self, tmp_path:Path) -> None:
"""Test that portable mode works with Unicode characters in CWD path. config_path = (tmp_path / "config.yaml").resolve()
original_resolve = Path.resolve
This tests the edge case where the current directory contains Unicode def patched_resolve(self:Path, strict:bool = False) -> object:
characters (e.g., user names with umlauts), which may be stored in if self == config_path:
different normalization forms (NFD on macOS, NFC on Linux/Windows). return None
""" return original_resolve(self, strict)
# Create directory with German umlaut in composed (NFC) form
# ä = U+00E4 (NFC) vs a + ̈ = U+0061 + U+0308 (NFD)
unicode_dir = tmp_path / "Müller_config"
unicode_dir.mkdir()
monkeypatch.chdir(unicode_dir)
# Get paths - should work regardless of normalization with patch.object(Path, "resolve", patched_resolve), pytest.raises(
config_path = xdg_paths.get_config_file_path("portable") RuntimeError, match = "Workspace mode and config path must be resolved"
log_path = xdg_paths.get_log_file_path("test", "portable") ):
xdg_paths.resolve_workspace(
# Verify paths are within the Unicode directory config_arg = str(config_path),
assert config_path.parent == unicode_dir logfile_arg = None,
assert log_path.parent == unicode_dir workspace_mode = "portable",
assert config_path.name == "config.yaml" logfile_explicitly_provided = False,
assert log_path.name == "test.log" log_basename = "kleinanzeigen-bot",
)
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"