mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 02:31:45 +01:00
fix: add explicit workspace mode resolution for --config (#818)
This commit is contained in:
33
README.md
33
README.md
@@ -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 ⚙️
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
if config_path is not None and config_path == (Path.cwd() / "config.yaml").resolve():
|
workspace_mode = effective_workspace_mode,
|
||||||
# Explicit path points to CWD config
|
logfile_explicitly_provided = self._logfile_explicitly_provided,
|
||||||
self.installation_mode = "portable"
|
log_basename = self._log_basename,
|
||||||
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:
|
|
||||||
# First run - prompt user
|
|
||||||
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
|
|
||||||
self.config_file_path = str(xdg_paths.get_config_file_path(self.installation_mode))
|
|
||||||
|
|
||||||
# Set log file path based on mode (unless explicitly overridden via --logfile)
|
|
||||||
using_default_portable_log = (
|
|
||||||
self.log_file_path is not None and Path(self.log_file_path).resolve() == xdg_paths.get_log_file_path(self.log_file_basename, "portable").resolve()
|
|
||||||
)
|
)
|
||||||
if not self.log_file_explicitly_provided and using_default_portable_log:
|
except ValueError as exc:
|
||||||
# Still using default portable path - update to match detected mode
|
LOG.error(str(exc))
|
||||||
self.log_file_path = str(xdg_paths.get_log_file_path(self.log_file_basename, self.installation_mode))
|
sys.exit(2)
|
||||||
LOG.debug("Log file path: %s", self.log_file_path)
|
|
||||||
|
|
||||||
# Log installation mode and config location (INFO level for user visibility)
|
xdg_paths.ensure_directory(self.workspace.config_file.parent, "config directory")
|
||||||
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)
|
self.config_file_path = str(self.workspace.config_file)
|
||||||
|
self.log_file_path = str(self.workspace.log_file) if self.workspace.log_file else None
|
||||||
|
|
||||||
|
LOG.info("Config: %s", self.workspace.config_file)
|
||||||
|
LOG.info("Workspace mode: %s", self.workspace.mode)
|
||||||
|
LOG.info("Workspace: %s", self.workspace.config_dir)
|
||||||
|
if loggers.is_debug(LOG):
|
||||||
|
LOG.debug("Log file: %s", self.workspace.log_file)
|
||||||
|
LOG.debug("State dir: %s", self.workspace.state_dir)
|
||||||
|
LOG.debug("Download dir: %s", self.workspace.download_dir)
|
||||||
|
LOG.debug("Browser profile: %s", self.workspace.browser_profile_dir)
|
||||||
|
LOG.debug("Diagnostics dir: %s", self.workspace.diagnostics_dir)
|
||||||
|
|
||||||
async def run(self, args:list[str]) -> None:
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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),
|
||||||
def test_portable_mode_handles_unicode_in_cwd(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
logfile_arg = None,
|
||||||
"""Test that portable mode works with Unicode characters in CWD path.
|
workspace_mode = None,
|
||||||
|
logfile_explicitly_provided = False,
|
||||||
This tests the edge case where the current directory contains Unicode
|
log_basename = "kleinanzeigen-bot",
|
||||||
characters (e.g., user names with umlauts), which may be stored in
|
|
||||||
different normalization forms (NFD on macOS, NFC on Linux/Windows).
|
|
||||||
"""
|
|
||||||
# Create directory with German umlaut in composed (NFC) form
|
|
||||||
# ä = U+00E4 (NFC) vs a + ̈ = U+0061 + U+0308 (NFD)
|
|
||||||
unicode_dir = tmp_path / "Müller_config"
|
|
||||||
unicode_dir.mkdir()
|
|
||||||
monkeypatch.chdir(unicode_dir)
|
|
||||||
|
|
||||||
# Get paths - should work regardless of normalization
|
|
||||||
config_path = xdg_paths.get_config_file_path("portable")
|
|
||||||
log_path = xdg_paths.get_log_file_path("test", "portable")
|
|
||||||
|
|
||||||
# Verify paths are within the Unicode directory
|
|
||||||
assert config_path.parent == unicode_dir
|
|
||||||
assert log_path.parent == unicode_dir
|
|
||||||
assert config_path.name == "config.yaml"
|
|
||||||
assert log_path.name == "test.log"
|
|
||||||
|
|
||||||
def test_xdg_mode_handles_unicode_in_paths(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
||||||
"""Test that XDG mode handles Unicode in XDG directory paths.
|
|
||||||
|
|
||||||
This tests the edge case where XDG directories contain Unicode
|
|
||||||
characters (e.g., /Users/Müller/.config/), which may be in NFD
|
|
||||||
form on macOS filesystems.
|
|
||||||
"""
|
|
||||||
# Create XDG directory with umlaut
|
|
||||||
xdg_base = tmp_path / "Users" / "Müller" / ".config"
|
|
||||||
xdg_base.mkdir(parents = True)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"platformdirs.user_config_dir",
|
|
||||||
lambda app_name, *args, **kwargs: str(xdg_base / app_name),
|
|
||||||
)
|
)
|
||||||
|
assert "Portable footprint hits: none" in str(exc_info.value)
|
||||||
|
assert "XDG footprint hits: none" in str(exc_info.value)
|
||||||
|
|
||||||
# Get config path
|
def test_resolve_workspace_raises_when_config_path_is_unresolved(self, tmp_path:Path) -> None:
|
||||||
config_path = xdg_paths.get_config_file_path("xdg")
|
config_path = (tmp_path / "config.yaml").resolve()
|
||||||
|
original_resolve = Path.resolve
|
||||||
|
|
||||||
# Verify path contains the Unicode directory
|
def patched_resolve(self:Path, strict:bool = False) -> object:
|
||||||
assert "Müller" in str(config_path) or "Mu\u0308ller" in str(config_path)
|
if self == config_path:
|
||||||
assert config_path.name == "config.yaml"
|
return None
|
||||||
|
return original_resolve(self, strict)
|
||||||
|
|
||||||
def test_downloaded_ads_path_handles_unicode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
with patch.object(Path, "resolve", patched_resolve), pytest.raises(
|
||||||
"""Test that downloaded ads directory creation works with Unicode paths."""
|
RuntimeError, match = "Workspace mode and config path must be resolved"
|
||||||
# Create XDG config directory with umlaut
|
):
|
||||||
xdg_config = tmp_path / "config" / "Müller"
|
xdg_paths.resolve_workspace(
|
||||||
xdg_config.mkdir(parents = True)
|
config_arg = str(config_path),
|
||||||
|
logfile_arg = None,
|
||||||
monkeypatch.setattr(
|
workspace_mode = "portable",
|
||||||
"platformdirs.user_config_dir",
|
logfile_explicitly_provided = False,
|
||||||
lambda app_name, *args, **kwargs: str(xdg_config / app_name),
|
log_basename = "kleinanzeigen-bot",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get downloaded ads path - this will create the directory
|
|
||||||
ads_path = xdg_paths.get_downloaded_ads_path("xdg")
|
|
||||||
|
|
||||||
# Verify directory was created successfully
|
|
||||||
assert ads_path.exists()
|
|
||||||
assert ads_path.is_dir()
|
|
||||||
assert ads_path.name == "downloaded-ads"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user