From e8cf10101d0ae9d79312eeaa8c188af0c94797b7 Mon Sep 17 00:00:00 2001 From: Jens <1742418+1cu@users.noreply.github.com> Date: Fri, 23 Jan 2026 07:36:10 +0100 Subject: [PATCH] feat: integrate XDG paths into bot core (#776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ℹ️ Description Wire XDG path resolution into main bot components. - Link to the related issue(s): N/A (new feature) - Integrates installation mode detection into bot core ## 📋 Changes Summary - Added `finalize_installation_mode()` method for mode detection - UpdateChecker, AdExtractor now respect installation mode - Dynamic browser profile defaults (resolved at runtime) - German translations for installation mode messages - Comprehensive tests for installation mode integration **Part 2 of 3 for XDG support** - Depends on: PR #775 (must be merged first) - Will rebase on main after merge of previous PR ### ⚙️ Type of Change - [x] ✨ New feature (adds new functionality without breaking existing usage) ## ✅ Checklist - [x] I have reviewed my changes to ensure they meet the project's standards. - [x] I have tested my changes and ensured that all tests pass (`pdm run test`). - [x] I have formatted the code (`pdm run format`). - [x] I have verified that linting passes (`pdm run lint`). - [x] I have updated documentation where necessary. By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. ## Summary by CodeRabbit * **New Features** * Support for portable and XDG (system-wide) installation modes with automatic detection and interactive first-run setup. * Config and paths standardized so app stores config, downloads, logs, and browser profiles in appropriate locations per mode. * Update checker improved for more reliable version/commit detection. * **Chores** * Moved dependency to runtime: platformdirs added to main dependencies. * **Tests** * Added comprehensive tests for installation modes, path utilities, and related behaviors. ✏️ Tip: You can customize this high-level summary in your review settings. --- src/kleinanzeigen_bot/__init__.py | 494 ++++---- src/kleinanzeigen_bot/extract.py | 68 +- src/kleinanzeigen_bot/model/config_model.py | 247 ++-- .../resources/translations.de.yaml | 7 +- src/kleinanzeigen_bot/update_checker.py | 41 +- tests/unit/test_extract.py | 1079 ++++++++--------- tests/unit/test_init.py | 754 ++++++------ 7 files changed, 1268 insertions(+), 1422 deletions(-) diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index 077b2b3..87134db 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -19,7 +19,7 @@ from ._version import __version__ from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, Contact, calculate_auto_price from .model.config_model import Config from .update_checker import UpdateChecker -from .utils import dicts, error_handlers, loggers, misc +from .utils import dicts, error_handlers, loggers, misc, xdg_paths from .utils.exceptions import CaptchaEncountered from .utils.files import abspath from .utils.i18n import Locale, get_current_locale, pluralize, set_current_locale @@ -28,7 +28,7 @@ from .utils.web_scraping_mixin import By, Element, Is, WebScrapingMixin # W0406: possibly a bug, see https://github.com/PyCQA/pylint/issues/3933 -LOG:Final[loggers.Logger] = loggers.get_logger(__name__) +LOG: Final[loggers.Logger] = loggers.get_logger(__name__) LOG.setLevel(loggers.INFO) colorama.just_fix_windows_console() @@ -39,7 +39,7 @@ class AdUpdateStrategy(enum.Enum): MODIFY = enum.auto() -def _repost_cycle_ready(ad_cfg:Ad, ad_file_relative:str) -> bool: +def _repost_cycle_ready(ad_cfg: Ad, ad_file_relative: str) -> bool: """ Check if the repost cycle delay has been satisfied. @@ -59,23 +59,20 @@ def _repost_cycle_ready(ad_cfg:Ad, ad_file_relative:str) -> bool: ad_file_relative, max(remaining, 1), # Clamp to 1 to avoid showing "0 more reposts" when at threshold total_reposts, - applied_cycles + applied_cycles, ) return False if eligible_cycles <= applied_cycles: LOG.debug( - _("Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts"), - ad_file_relative, - applied_cycles, - eligible_cycles + _("Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts"), ad_file_relative, applied_cycles, eligible_cycles ) return False return True -def _day_delay_elapsed(ad_cfg:Ad, ad_file_relative:str) -> bool: +def _day_delay_elapsed(ad_cfg: Ad, ad_file_relative: str) -> bool: """ Check if the day delay has elapsed since the ad was last published. @@ -89,11 +86,7 @@ def _day_delay_elapsed(ad_cfg:Ad, ad_file_relative:str) -> bool: reference = ad_cfg.updated_on or ad_cfg.created_on if not reference: - LOG.info( - _("Auto price reduction delayed for [%s]: waiting %s days but publish timestamp missing"), - ad_file_relative, - delay_days - ) + LOG.info(_("Auto price reduction delayed for [%s]: waiting %s days but publish timestamp missing"), ad_file_relative, delay_days) return False # Note: .days truncates to whole days (e.g., 1.9 days -> 1 day) @@ -101,18 +94,13 @@ def _day_delay_elapsed(ad_cfg:Ad, ad_file_relative:str) -> bool: # Both misc.now() and stored timestamps use UTC (via misc.now()), ensuring consistent calculations elapsed_days = (misc.now() - reference).days if elapsed_days < delay_days: - LOG.info( - _("Auto price reduction delayed for [%s]: waiting %s days (elapsed %s)"), - ad_file_relative, - delay_days, - elapsed_days - ) + LOG.info(_("Auto price reduction delayed for [%s]: waiting %s days (elapsed %s)"), ad_file_relative, delay_days, elapsed_days) return False return True -def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> None: +def apply_auto_price_reduction(ad_cfg: Ad, _ad_cfg_orig: dict[str, Any], ad_file_relative: str) -> None: """ Apply automatic price reduction to an ad based on repost count and configuration. @@ -132,11 +120,7 @@ def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_r return if ad_cfg.auto_price_reduction.min_price is not None and ad_cfg.auto_price_reduction.min_price == base_price: - LOG.warning( - _("Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur."), - ad_file_relative, - base_price - ) + LOG.warning(_("Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur."), ad_file_relative, base_price) return if not _repost_cycle_ready(ad_cfg, ad_file_relative): @@ -148,11 +132,7 @@ def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_r applied_cycles = ad_cfg.price_reduction_count or 0 next_cycle = applied_cycles + 1 - effective_price = calculate_auto_price( - base_price = base_price, - auto_price_reduction = ad_cfg.auto_price_reduction, - target_reduction_cycle = next_cycle - ) + effective_price = calculate_auto_price(base_price=base_price, auto_price_reduction=ad_cfg.auto_price_reduction, target_reduction_cycle=next_cycle) if effective_price is None: return @@ -160,28 +140,17 @@ def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_r if effective_price == base_price: # Still increment counter so small fractional reductions can accumulate over multiple cycles ad_cfg.price_reduction_count = next_cycle - LOG.info( - _("Auto price reduction kept price %s after attempting %s reduction cycles"), - effective_price, - next_cycle - ) + LOG.info(_("Auto price reduction kept price %s after attempting %s reduction cycles"), effective_price, next_cycle) return - LOG.info( - _("Auto price reduction applied: %s -> %s after %s reduction cycles"), - base_price, - effective_price, - next_cycle - ) + LOG.info(_("Auto price reduction applied: %s -> %s after %s reduction cycles"), base_price, effective_price, next_cycle) ad_cfg.price = effective_price ad_cfg.price_reduction_count = next_cycle # Note: price_reduction_count is persisted to ad_cfg_orig only after successful publish class KleinanzeigenBot(WebScrapingMixin): - def __init__(self) -> None: - # workaround for https://github.com/Second-Hand-Friends/kleinanzeigen-bot/issues/295 # see https://github.com/pyinstaller/pyinstaller/issues/7229#issuecomment-1309383026 os.environ["SSL_CERT_FILE"] = certifi.where() @@ -190,14 +159,19 @@ class KleinanzeigenBot(WebScrapingMixin): self.root_url = "https://www.kleinanzeigen.de" - self.config:Config + self.config: Config self.config_file_path = abspath("config.yaml") + self.config_explicitly_provided = False - self.categories:dict[str, str] = {} + self.installation_mode: xdg_paths.InstallationMode | None = None - self.file_log:loggers.LogFileHandle | None = None + self.categories: dict[str, str] = {} + + self.file_log: loggers.LogFileHandle | None = None log_file_basename = is_frozen() and os.path.splitext(os.path.basename(sys.executable))[0] or self.__module__ - self.log_file_path:str | None = abspath(f"{log_file_basename}.log") + self.log_file_path: str | None = abspath(f"{log_file_basename}.log") + self.log_file_basename = log_file_basename + self.log_file_explicitly_provided = False self.command = "help" self.ads_selector = "due" @@ -209,11 +183,71 @@ class KleinanzeigenBot(WebScrapingMixin): self.file_log = None self.close_browser_session() + @property + def installation_mode_or_portable(self) -> xdg_paths.InstallationMode: + return self.installation_mode or "portable" + def get_version(self) -> str: return __version__ - async def run(self, args:list[str]) -> None: + def finalize_installation_mode(self) -> None: + """ + Finalize installation mode detection after CLI args are parsed. + Must be called after parse_args() to respect --config overrides. + """ + if self.command in {"help", "version"}: + return + # Check if config_file_path was already customized (by --config or tests) + default_portable_config = xdg_paths.get_config_file_path("portable").resolve() + config_path = Path(self.config_file_path).resolve() if self.config_file_path else None + config_was_customized = self.config_explicitly_provided or (config_path is not None and config_path != default_portable_config) + + if config_was_customized and self.config_file_path: + # Config path was explicitly set - detect mode based on it + LOG.debug("Detecting installation mode from explicit config path: %s", self.config_file_path) + + if config_path is not None and config_path == (Path.cwd() / "config.yaml").resolve(): + # Explicit path points to CWD config + self.installation_mode = "portable" + LOG.debug("Explicit config is in CWD, using portable mode") + elif config_path is not None and config_path.is_relative_to(xdg_paths.get_xdg_base_dir("config").resolve()): + # Explicit path is within XDG config directory + self.installation_mode = "xdg" + LOG.debug("Explicit config is in XDG directory, using xdg mode") + else: + # Custom location - default to portable mode (all paths relative to config) + self.installation_mode = "portable" + LOG.debug("Explicit config is in custom location, defaulting to portable mode") + else: + # No explicit config - use auto-detection + LOG.debug("Detecting installation mode...") + self.installation_mode = xdg_paths.detect_installation_mode() + + 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: + # Still using default portable path - update to match detected mode + self.log_file_path = str(xdg_paths.get_log_file_path(self.log_file_basename, self.installation_mode)) + LOG.debug("Log file path: %s", self.log_file_path) + + # Log installation mode and config location (INFO level for user visibility) + mode_display = "portable (current directory)" if self.installation_mode == "portable" else "system-wide (XDG directories)" + LOG.info(_("Installation mode: %s"), mode_display) + LOG.info(_("Config file: %s"), self.config_file_path) + + async def run(self, args: list[str]) -> None: self.parse_args(args) + self.finalize_installation_mode() try: match self.command: case "help": @@ -233,7 +267,7 @@ class KleinanzeigenBot(WebScrapingMixin): self.configure_file_logging() self.load_config() # Check for updates on startup - checker = UpdateChecker(self.config) + checker = UpdateChecker(self.config, self.installation_mode_or_portable) checker.check_for_updates() self.load_ads() LOG.info("############################################") @@ -242,16 +276,16 @@ class KleinanzeigenBot(WebScrapingMixin): case "update-check": self.configure_file_logging() self.load_config() - checker = UpdateChecker(self.config) - checker.check_for_updates(skip_interval_check = True) + checker = UpdateChecker(self.config, self.installation_mode_or_portable) + checker.check_for_updates(skip_interval_check=True) case "update-content-hash": self.configure_file_logging() self.load_config() # Check for updates on startup - checker = UpdateChecker(self.config) + checker = UpdateChecker(self.config, self.installation_mode_or_portable) checker.check_for_updates() self.ads_selector = "all" - if ads := self.load_ads(exclude_ads_with_id = False): + if ads := self.load_ads(exclude_ads_with_id=False): self.update_content_hashes(ads) else: LOG.info("############################################") @@ -261,12 +295,14 @@ class KleinanzeigenBot(WebScrapingMixin): self.configure_file_logging() self.load_config() # Check for updates on startup - checker = UpdateChecker(self.config) + checker = UpdateChecker(self.config, self.installation_mode_or_portable) checker.check_for_updates() - if not (self.ads_selector in {"all", "new", "due", "changed"} or - any(selector in self.ads_selector.split(",") for selector in ("all", "new", "due", "changed")) or - re.compile(r"\d+[,\d+]*").search(self.ads_selector)): + if not ( + self.ads_selector in {"all", "new", "due", "changed"} + or any(selector in self.ads_selector.split(",") for selector in ("all", "new", "due", "changed")) + or re.compile(r"\d+[,\d+]*").search(self.ads_selector) + ): LOG.warning('You provided no ads selector. Defaulting to "due".') self.ads_selector = "due" @@ -282,10 +318,11 @@ class KleinanzeigenBot(WebScrapingMixin): self.configure_file_logging() self.load_config() - if not (self.ads_selector in {"all", "changed"} or - any(selector in self.ads_selector.split(",") for selector in - ("all", "changed")) or - re.compile(r"\d+[,\d+]*").search(self.ads_selector)): + if not ( + self.ads_selector in {"all", "changed"} + or any(selector in self.ads_selector.split(",") for selector in ("all", "changed")) + or re.compile(r"\d+[,\d+]*").search(self.ads_selector) + ): LOG.warning('You provided no ads selector. Defaulting to "changed".') self.ads_selector = "changed" @@ -301,7 +338,7 @@ class KleinanzeigenBot(WebScrapingMixin): self.configure_file_logging() self.load_config() # Check for updates on startup - checker = UpdateChecker(self.config) + checker = UpdateChecker(self.config, self.installation_mode_or_portable) checker.check_for_updates() if ads := self.load_ads(): await self.create_browser_session() @@ -315,7 +352,7 @@ class KleinanzeigenBot(WebScrapingMixin): self.configure_file_logging() self.load_config() # Check for updates on startup - checker = UpdateChecker(self.config) + checker = UpdateChecker(self.config, self.installation_mode_or_portable) checker.check_for_updates() # Default to all ads if no selector provided @@ -339,7 +376,7 @@ class KleinanzeigenBot(WebScrapingMixin): self.ads_selector = "new" self.load_config() # Check for updates on startup - checker = UpdateChecker(self.config) + checker = UpdateChecker(self.config, self.installation_mode_or_portable) checker.check_for_updates() await self.create_browser_session() await self.login() @@ -360,7 +397,9 @@ class KleinanzeigenBot(WebScrapingMixin): exe = "python -m kleinanzeigen_bot" if get_current_locale().language == "de": - print(textwrap.dedent(f"""\ + print( + textwrap.dedent( + f"""\ Verwendung: {colorama.Fore.LIGHTMAGENTA_EX}{exe} BEFEHL [OPTIONEN]{colorama.Style.RESET_ALL} Befehle: @@ -408,9 +447,13 @@ class KleinanzeigenBot(WebScrapingMixin): --logfile= - Pfad zur Protokolldatei (STANDARD: ./kleinanzeigen-bot.log) --lang=en|de - Anzeigesprache (STANDARD: Systemsprache, wenn unterstützt, sonst Englisch) -v, --verbose - Aktiviert detaillierte Ausgabe – nur nützlich zur Fehlerbehebung - """.rstrip())) + """.rstrip() + ) + ) else: - print(textwrap.dedent(f"""\ + print( + textwrap.dedent( + f"""\ Usage: {colorama.Fore.LIGHTMAGENTA_EX}{exe} COMMAND [OPTIONS]{colorama.Style.RESET_ALL} Commands: @@ -456,20 +499,13 @@ class KleinanzeigenBot(WebScrapingMixin): --logfile= - path to the logfile (DEFAULT: ./kleinanzeigen-bot.log) --lang=en|de - display language (STANDARD: system language if supported, otherwise English) -v, --verbose - enables verbose output - only useful when troubleshooting issues - """.rstrip())) + """.rstrip() + ) + ) - def parse_args(self, args:list[str]) -> None: + def parse_args(self, args: list[str]) -> None: try: - options, arguments = getopt.gnu_getopt(args[1:], "hv", [ - "ads=", - "config=", - "force", - "help", - "keep-old", - "logfile=", - "lang=", - "verbose" - ]) + options, arguments = getopt.gnu_getopt(args[1:], "hv", ["ads=", "config=", "force", "help", "keep-old", "logfile=", "lang=", "verbose"]) except getopt.error as ex: LOG.error(ex.msg) LOG.error("Use --help to display available options.") @@ -482,11 +518,13 @@ class KleinanzeigenBot(WebScrapingMixin): sys.exit(0) case "--config": self.config_file_path = abspath(value) + self.config_explicitly_provided = True case "--logfile": if value: self.log_file_path = abspath(value) else: self.log_file_path = None + self.log_file_explicitly_provided = True case "--ads": self.ads_selector = value.strip().lower() case "--force": @@ -533,12 +571,12 @@ class KleinanzeigenBot(WebScrapingMixin): default_config.login.password = "changeme" # noqa: S105 placeholder for default config, not a real password dicts.save_dict( self.config_file_path, - default_config.model_dump(exclude_none = True, exclude = {"ad_defaults": {"description"}}), - header = ( + default_config.model_dump(exclude_none=True, exclude={"ad_defaults": {"description"}}), + header=( "# yaml-language-server: $schema=" "https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot" "/refs/heads/main/schemas/config.schema.json" - ) + ), ) def load_config(self) -> None: @@ -547,7 +585,7 @@ class KleinanzeigenBot(WebScrapingMixin): self.create_default_config() config_yaml = dicts.load_dict_if_exists(self.config_file_path, _("config")) - self.config = Config.model_validate(config_yaml, strict = True, context = self.config_file_path) + self.config = Config.model_validate(config_yaml, strict=True, context=self.config_file_path) # load built-in category mappings self.categories = dicts.load_dict_from_module(resources, "categories.yaml", "categories") @@ -560,13 +598,13 @@ class KleinanzeigenBot(WebScrapingMixin): # populate browser_config object used by WebScrapingMixin self.browser_config.arguments = self.config.browser.arguments self.browser_config.binary_location = self.config.browser.binary_location - self.browser_config.extensions = [abspath(item, relative_to = self.config_file_path) for item in self.config.browser.extensions] + self.browser_config.extensions = [abspath(item, relative_to=self.config_file_path) for item in self.config.browser.extensions] self.browser_config.use_private_window = self.config.browser.use_private_window if self.config.browser.user_data_dir: - self.browser_config.user_data_dir = abspath(self.config.browser.user_data_dir, relative_to = self.config_file_path) + self.browser_config.user_data_dir = abspath(self.config.browser.user_data_dir, relative_to=self.config_file_path) 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: """ Check if an ad needs to be republished based on republication interval. Note: This method does not check for content changes. Use __check_ad_changed for that. @@ -591,13 +629,13 @@ class KleinanzeigenBot(WebScrapingMixin): " -> SKIPPED: ad [%s] was last published %d days ago. republication is only required every %s days", ad_file_relative, ad_age.days, - ad_cfg.republication_interval + ad_cfg.republication_interval, ) return False return True - def __check_ad_changed(self, ad_cfg:Ad, ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> bool: + def __check_ad_changed(self, ad_cfg: Ad, ad_cfg_orig: dict[str, Any], ad_file_relative: str) -> bool: """ Check if an ad has been changed since last publication. @@ -624,7 +662,7 @@ class KleinanzeigenBot(WebScrapingMixin): return False - def load_ads(self, *, ignore_inactive:bool = True, exclude_ads_with_id:bool = True) -> list[tuple[str, Ad, dict[str, Any]]]: + def load_ads(self, *, ignore_inactive: bool = True, exclude_ads_with_id: bool = True) -> list[tuple[str, Ad, dict[str, Any]]]: """ Load and validate all ad config files, optionally filtering out inactive or already-published ads. @@ -640,12 +678,12 @@ class KleinanzeigenBot(WebScrapingMixin): """ LOG.info("Searching for ad config files...") - ad_files:dict[str, str] = {} + ad_files: dict[str, str] = {} data_root_dir = os.path.dirname(self.config_file_path) for file_pattern in self.config.ad_files: - for ad_file in glob.glob(file_pattern, root_dir = data_root_dir, flags = glob.GLOBSTAR | glob.BRACE | glob.EXTGLOB): + for ad_file in glob.glob(file_pattern, root_dir=data_root_dir, flags=glob.GLOBSTAR | glob.BRACE | glob.EXTGLOB): if not str(ad_file).endswith("ad_fields.yaml"): - ad_files[abspath(ad_file, relative_to = data_root_dir)] = ad_file + ad_files[abspath(ad_file, relative_to=data_root_dir)] = ad_file LOG.info(" -> found %s", pluralize("ad config file", ad_files)) if not ad_files: return [] @@ -662,8 +700,8 @@ class KleinanzeigenBot(WebScrapingMixin): ads = [] for ad_file, ad_file_relative in sorted(ad_files.items()): - ad_cfg_orig:dict[str, Any] = dicts.load_dict(ad_file, "ad") - ad_cfg:Ad = self.load_ad(ad_cfg_orig) + ad_cfg_orig: dict[str, Any] = dicts.load_dict(ad_file, "ad") + ad_cfg: Ad = self.load_ad(ad_cfg_orig) if ignore_inactive and not ad_cfg.active: LOG.info(" -> SKIPPED: inactive ad [%s]", ad_file_relative) @@ -700,8 +738,8 @@ class KleinanzeigenBot(WebScrapingMixin): if not should_include: continue - ensure(self.__get_description(ad_cfg, with_affixes = False), f"-> property [description] not specified @ [{ad_file}]") - self.__get_description(ad_cfg, with_affixes = True) # validates complete description + ensure(self.__get_description(ad_cfg, with_affixes=False), f"-> property [description] not specified @ [{ad_file}]") + self.__get_description(ad_cfg, with_affixes=True) # validates complete description if ad_cfg.category: resolved_category_id = self.categories.get(ad_cfg.category) @@ -710,9 +748,7 @@ class KleinanzeigenBot(WebScrapingMixin): parent_category = ad_cfg.category.rpartition(">")[0].strip() resolved_category_id = self.categories.get(parent_category) if resolved_category_id: - LOG.warning( - "Category [%s] unknown. Using category [%s] with ID [%s] instead.", - ad_cfg.category, parent_category, resolved_category_id) + LOG.warning("Category [%s] unknown. Using category [%s] with ID [%s] instead.", ad_cfg.category, parent_category, resolved_category_id) if resolved_category_id: ad_cfg.category = resolved_category_id @@ -722,34 +758,29 @@ class KleinanzeigenBot(WebScrapingMixin): ad_dir = os.path.dirname(ad_file) for image_pattern in ad_cfg.images: pattern_images = set() - for image_file in glob.glob(image_pattern, root_dir = ad_dir, flags = glob.GLOBSTAR | glob.BRACE | glob.EXTGLOB): + for image_file in glob.glob(image_pattern, root_dir=ad_dir, flags=glob.GLOBSTAR | glob.BRACE | glob.EXTGLOB): _, image_file_ext = os.path.splitext(image_file) ensure(image_file_ext.lower() in {".gif", ".jpg", ".jpeg", ".png"}, f"Unsupported image file type [{image_file}]") if os.path.isabs(image_file): pattern_images.add(image_file) else: - pattern_images.add(abspath(image_file, relative_to = ad_file)) + pattern_images.add(abspath(image_file, relative_to=ad_file)) images.extend(sorted(pattern_images)) ensure(images or not ad_cfg.images, f"No images found for given file patterns {ad_cfg.images} at {ad_dir}") ad_cfg.images = list(dict.fromkeys(images)) - ads.append(( - ad_file, - ad_cfg, - ad_cfg_orig - )) + ads.append((ad_file, ad_cfg, ad_cfg_orig)) LOG.info("Loaded %s", pluralize("ad", ads)) return ads - def load_ad(self, ad_cfg_orig:dict[str, Any]) -> Ad: + def load_ad(self, ad_cfg_orig: dict[str, Any]) -> Ad: return AdPartial.model_validate(ad_cfg_orig).to_ad(self.config.ad_defaults) - async def check_and_wait_for_captcha(self, *, is_login_page:bool = True) -> None: + async def check_and_wait_for_captcha(self, *, is_login_page: bool = True) -> None: try: captcha_timeout = self._timeout("captcha_detection") - await self.web_find(By.CSS_SELECTOR, - "iframe[name^='a-'][src^='https://www.google.com/recaptcha/api2/anchor?']", timeout = captcha_timeout) + await self.web_find(By.CSS_SELECTOR, "iframe[name^='a-'][src^='https://www.google.com/recaptcha/api2/anchor?']", timeout=captcha_timeout) if not is_login_page and self.config.captcha.auto_restart: LOG.warning("Captcha recognized - auto-restart enabled, abort run...") @@ -802,14 +833,14 @@ class KleinanzeigenBot(WebScrapingMixin): await self.web_input(By.ID, "login-password", "") await self.web_input(By.ID, "login-password", self.config.login.password) - await self.check_and_wait_for_captcha(is_login_page = True) + await self.check_and_wait_for_captcha(is_login_page=True) await self.web_click(By.CSS_SELECTOR, "form#login-form button[type='submit']") async def handle_after_login_logic(self) -> None: try: sms_timeout = self._timeout("sms_verification") - await self.web_find(By.TEXT, "Wir haben dir gerade einen 6-stelligen Code für die Telefonnummer", timeout = sms_timeout) + await self.web_find(By.TEXT, "Wir haben dir gerade einen 6-stelligen Code für die Telefonnummer", timeout=sms_timeout) LOG.warning("############################################") LOG.warning("# Device verification message detected. Please follow the instruction displayed in the Browser.") LOG.warning("############################################") @@ -821,11 +852,11 @@ class KleinanzeigenBot(WebScrapingMixin): try: LOG.info("Handling GDPR disclaimer...") gdpr_timeout = self._timeout("gdpr_prompt") - await self.web_find(By.ID, "gdpr-banner-accept", timeout = gdpr_timeout) + await self.web_find(By.ID, "gdpr-banner-accept", timeout=gdpr_timeout) await self.web_click(By.ID, "gdpr-banner-cmp-button") - await self.web_click(By.XPATH, - "//div[@id='ConsentManagementPage']//*//button//*[contains(., 'Alle ablehnen und fortfahren')]", - timeout = gdpr_timeout) + await self.web_click( + By.XPATH, "//div[@id='ConsentManagementPage']//*//button//*[contains(., 'Alle ablehnen und fortfahren')]", timeout=gdpr_timeout + ) except TimeoutError: # GDPR banner not shown within timeout. pass @@ -842,7 +873,7 @@ class KleinanzeigenBot(WebScrapingMixin): # Try to find the standard element first try: - user_info = await self.web_text(By.CLASS_NAME, "mr-medium", timeout = login_check_timeout) + user_info = await self.web_text(By.CLASS_NAME, "mr-medium", timeout=login_check_timeout) if username in user_info.lower(): LOG.debug(_("Login detected via .mr-medium element")) return True @@ -851,7 +882,7 @@ class KleinanzeigenBot(WebScrapingMixin): # If standard element not found or didn't contain username, try the alternative try: - user_info = await self.web_text(By.ID, "user-email", timeout = login_check_timeout) + user_info = await self.web_text(By.ID, "user-email", timeout=login_check_timeout) if username in user_info.lower(): LOG.debug(_("Login detected via #user-email element")) return True @@ -861,23 +892,22 @@ class KleinanzeigenBot(WebScrapingMixin): LOG.debug(_("No login detected - neither .mr-medium nor #user-email found with username")) return False - async def delete_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None: + async def delete_ads(self, ad_cfgs: list[tuple[str, Ad, dict[str, Any]]]) -> None: count = 0 - published_ads = json.loads( - (await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"] + published_ads = json.loads((await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"] - for (ad_file, ad_cfg, _ad_cfg_orig) in ad_cfgs: + for ad_file, ad_cfg, _ad_cfg_orig in ad_cfgs: count += 1 LOG.info("Processing %s/%s: '%s' from [%s]...", count, len(ad_cfgs), ad_cfg.title, ad_file) - await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = self.config.publishing.delete_old_ads_by_title) + await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title=self.config.publishing.delete_old_ads_by_title) await self.web_sleep() LOG.info("############################################") LOG.info("DONE: Deleted %s", pluralize("ad", count)) LOG.info("############################################") - async def delete_ad(self, ad_cfg:Ad, published_ads:list[dict[str, Any]], *, delete_old_ads_by_title:bool) -> bool: + async def delete_ad(self, ad_cfg: Ad, published_ads: list[dict[str, Any]], *, delete_old_ads_by_title: bool) -> bool: LOG.info("Deleting ad '%s' if already present...", ad_cfg.title) await self.web_open(f"{self.root_url}/m-meine-anzeigen.html") @@ -886,38 +916,34 @@ class KleinanzeigenBot(WebScrapingMixin): ensure(csrf_token is not None, "Expected CSRF Token not found in HTML content!") if delete_old_ads_by_title: - for published_ad in published_ads: published_ad_id = int(published_ad.get("id", -1)) published_ad_title = published_ad.get("title", "") if ad_cfg.id == published_ad_id or ad_cfg.title == published_ad_title: LOG.info(" -> deleting %s '%s'...", published_ad_id, published_ad_title) await self.web_request( - url = f"{self.root_url}/m-anzeigen-loeschen.json?ids={published_ad_id}", - method = "POST", - headers = {"x-csrf-token": str(csrf_token)} + url=f"{self.root_url}/m-anzeigen-loeschen.json?ids={published_ad_id}", method="POST", headers={"x-csrf-token": str(csrf_token)} ) elif ad_cfg.id: await self.web_request( - url = f"{self.root_url}/m-anzeigen-loeschen.json?ids={ad_cfg.id}", - method = "POST", - headers = {"x-csrf-token": str(csrf_token)}, - valid_response_codes = [200, 404] + url=f"{self.root_url}/m-anzeigen-loeschen.json?ids={ad_cfg.id}", + method="POST", + headers={"x-csrf-token": str(csrf_token)}, + valid_response_codes=[200, 404], ) await self.web_sleep() ad_cfg.id = None return True - async def extend_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None: + async def extend_ads(self, ad_cfgs: list[tuple[str, Ad, dict[str, Any]]]) -> None: """Extends ads that are close to expiry.""" # Fetch currently published ads from API - published_ads = json.loads( - (await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"] + published_ads = json.loads((await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"] # Filter ads that need extension ads_to_extend = [] - for (ad_file, ad_cfg, ad_cfg_orig) in ad_cfgs: + for ad_file, ad_cfg, ad_cfg_orig in ad_cfgs: # Skip unpublished ads (no ID) if not ad_cfg.id: LOG.info(_(" -> SKIPPED: ad '%s' is not published yet"), ad_cfg.title) @@ -949,8 +975,7 @@ class KleinanzeigenBot(WebScrapingMixin): LOG.info(_(" -> ad '%s' expires in %d days, will extend"), ad_cfg.title, days_until_expiry) ads_to_extend.append((ad_file, ad_cfg, ad_cfg_orig, published_ad)) else: - LOG.info(_(" -> SKIPPED: ad '%s' expires in %d days (can only extend within 8 days)"), - ad_cfg.title, days_until_expiry) + LOG.info(_(" -> SKIPPED: ad '%s' expires in %d days (can only extend within 8 days)"), ad_cfg.title, days_until_expiry) if not ads_to_extend: LOG.info(_("No ads need extension at this time.")) @@ -961,7 +986,7 @@ class KleinanzeigenBot(WebScrapingMixin): # Process extensions success_count = 0 - for idx, (ad_file, ad_cfg, ad_cfg_orig, _published_ad) in enumerate(ads_to_extend, start = 1): + for idx, (ad_file, ad_cfg, ad_cfg_orig, _published_ad) in enumerate(ads_to_extend, start=1): LOG.info(_("Processing %s/%s: '%s' from [%s]..."), idx, len(ads_to_extend), ad_cfg.title, ad_file) if await self.extend_ad(ad_file, ad_cfg, ad_cfg_orig): success_count += 1 @@ -971,7 +996,7 @@ class KleinanzeigenBot(WebScrapingMixin): LOG.info(_("DONE: Extended %s"), pluralize("ad", success_count)) LOG.info("############################################") - async def extend_ad(self, ad_file:str, ad_cfg:Ad, ad_cfg_orig:dict[str, Any]) -> bool: + async def extend_ad(self, ad_file: str, ad_cfg: Ad, ad_cfg_orig: dict[str, Any]) -> bool: """Extends a single ad listing.""" LOG.info(_("Extending ad '%s' (ID: %s)..."), ad_cfg.title, ad_cfg.id) @@ -996,14 +1021,14 @@ class KleinanzeigenBot(WebScrapingMixin): # Simply close the dialog with the X button (aria-label="Schließen") try: dialog_close_timeout = self._timeout("quick_dom") - await self.web_click(By.CSS_SELECTOR, 'button[aria-label="Schließen"]', timeout = dialog_close_timeout) + await self.web_click(By.CSS_SELECTOR, 'button[aria-label="Schließen"]', timeout=dialog_close_timeout) LOG.debug(" -> Closed confirmation dialog") except TimeoutError: LOG.warning(_(" -> No confirmation dialog found, extension may have completed directly")) # Update metadata in YAML file # Update updated_on to track when ad was extended - ad_cfg_orig["updated_on"] = misc.now().isoformat(timespec = "seconds") + ad_cfg_orig["updated_on"] = misc.now().isoformat(timespec="seconds") dicts.save_dict(ad_file, ad_cfg_orig) LOG.info(_(" -> SUCCESS: ad extended with ID %s"), ad_cfg.id) @@ -1020,15 +1045,14 @@ class KleinanzeigenBot(WebScrapingMixin): # Check for success messages return await self.web_check(By.ID, "checking-done", Is.DISPLAYED) or await self.web_check(By.ID, "not-completed", Is.DISPLAYED) - async def publish_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None: + async def publish_ads(self, ad_cfgs: list[tuple[str, Ad, dict[str, Any]]]) -> None: count = 0 failed_count = 0 max_retries = 3 - published_ads = json.loads( - (await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"] + published_ads = json.loads((await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"] - for (ad_file, ad_cfg, ad_cfg_orig) in ad_cfgs: + for ad_file, ad_cfg, ad_cfg_orig in ad_cfgs: LOG.info("Processing %s/%s: '%s' from [%s]...", count + 1, len(ad_cfgs), ad_cfg.title, ad_file) if [x for x in published_ads if x["id"] == ad_cfg.id and x["state"] == "paused"]: @@ -1058,12 +1082,12 @@ class KleinanzeigenBot(WebScrapingMixin): if success: try: publish_timeout = self._timeout("publishing_result") - await self.web_await(self.__check_publishing_result, timeout = publish_timeout) + await self.web_await(self.__check_publishing_result, timeout=publish_timeout) except TimeoutError: LOG.warning(_(" -> Could not confirm publishing for '%s', but ad may be online"), ad_cfg.title) if success and self.config.publishing.delete_old_ads == "AFTER_PUBLISH" and not self.keep_old_ads: - await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = False) + await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title=False) LOG.info("############################################") if failed_count > 0: @@ -1072,8 +1096,9 @@ class KleinanzeigenBot(WebScrapingMixin): LOG.info(_("DONE: (Re-)published %s"), pluralize("ad", count)) LOG.info("############################################") - async def publish_ad(self, ad_file:str, ad_cfg:Ad, ad_cfg_orig:dict[str, Any], published_ads:list[dict[str, Any]], - mode:AdUpdateStrategy = AdUpdateStrategy.REPLACE) -> None: + async def publish_ad( + self, ad_file: str, ad_cfg: Ad, ad_cfg_orig: dict[str, Any], published_ads: list[dict[str, Any]], mode: AdUpdateStrategy = AdUpdateStrategy.REPLACE + ) -> None: """ @param ad_cfg: the effective ad config (i.e. with default values applied etc.) @param ad_cfg_orig: the ad config as present in the YAML file @@ -1083,7 +1108,7 @@ class KleinanzeigenBot(WebScrapingMixin): if mode == AdUpdateStrategy.REPLACE: if self.config.publishing.delete_old_ads == "BEFORE_PUBLISH" and not self.keep_old_ads: - await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = self.config.publishing.delete_old_ads_by_title) + await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title=self.config.publishing.delete_old_ads_by_title) # Apply auto price reduction only for REPLACE operations (actual reposts) # This ensures price reductions only happen on republish, not on UPDATE @@ -1172,12 +1197,12 @@ class KleinanzeigenBot(WebScrapingMixin): elif not await self.web_check(By.ID, "radio-buy-now-no", Is.SELECTED): await self.web_click(By.ID, "radio-buy-now-no") except TimeoutError as ex: - LOG.debug(ex, exc_info = True) + LOG.debug(ex, exc_info=True) ############################# # set description ############################# - description = self.__get_description(ad_cfg, with_affixes = True) + description = self.__get_description(ad_cfg, with_affixes=True) await self.web_execute("document.querySelector('#pstad-descrptn').value = `" + description.replace("`", "'") + "`") await self.__set_contact_fields(ad_cfg.contact) @@ -1186,10 +1211,9 @@ class KleinanzeigenBot(WebScrapingMixin): ############################# # delete previous images because we don't know which have changed ############################# - img_items = await self.web_find_all(By.CSS_SELECTOR, - "ul#j-pictureupload-thumbnails > li:not(.is-placeholder)") + img_items = await self.web_find_all(By.CSS_SELECTOR, "ul#j-pictureupload-thumbnails > li:not(.is-placeholder)") for element in img_items: - btn = await self.web_find(By.CSS_SELECTOR, "button.pictureupload-thumbnails-remove", parent = element) + btn = await self.web_find(By.CSS_SELECTOR, "button.pictureupload-thumbnails-remove", parent=element) await btn.click() ############################# @@ -1200,7 +1224,7 @@ class KleinanzeigenBot(WebScrapingMixin): ############################# # wait for captcha ############################# - await self.check_and_wait_for_captcha(is_login_page = False) + await self.check_and_wait_for_captcha(is_login_page=False) ############################# # submit @@ -1226,7 +1250,7 @@ class KleinanzeigenBot(WebScrapingMixin): ############################# try: short_timeout = self._timeout("quick_dom") - await self.web_find(By.ID, "myftr-shppngcrt-frm", timeout = short_timeout) + await self.web_find(By.ID, "myftr-shppngcrt-frm", timeout=short_timeout) LOG.warning("############################################") LOG.warning("# Payment form detected! Please proceed with payment.") @@ -1238,7 +1262,7 @@ class KleinanzeigenBot(WebScrapingMixin): pass confirmation_timeout = self._timeout("publishing_confirmation") - await self.web_await(lambda: "p-anzeige-aufgeben-bestaetigung.html?adId=" in self.page.url, timeout = confirmation_timeout) + await self.web_await(lambda: "p-anzeige-aufgeben-bestaetigung.html?adId=" in self.page.url, timeout=confirmation_timeout) # extract the ad id from the URL's query parameter current_url_query_params = urllib_parse.parse_qs(urllib_parse.urlparse(self.page.url).query) @@ -1248,7 +1272,7 @@ class KleinanzeigenBot(WebScrapingMixin): # Update content hash after successful publication # Calculate hash on original config to ensure consistent comparison on restart ad_cfg_orig["content_hash"] = AdPartial.model_validate(ad_cfg_orig).update_content_hash().content_hash - ad_cfg_orig["updated_on"] = misc.now().isoformat(timespec = "seconds") + ad_cfg_orig["updated_on"] = misc.now().isoformat(timespec="seconds") if not ad_cfg.created_on and not ad_cfg.id: ad_cfg_orig["created_on"] = ad_cfg_orig["updated_on"] @@ -1275,7 +1299,7 @@ class KleinanzeigenBot(WebScrapingMixin): dicts.save_dict(ad_file, ad_cfg_orig) - async def __set_contact_fields(self, contact:Contact) -> None: + async def __set_contact_fields(self, contact: Contact) -> None: ############################# # set contact zipcode ############################# @@ -1354,11 +1378,13 @@ class KleinanzeigenBot(WebScrapingMixin): await self.web_input(By.ID, "postad-phonenumber", contact.phone) except TimeoutError: LOG.warning( - _("Phone number field not present on page. This is expected for many private accounts; " - "commercial accounts may still support phone numbers.") + _( + "Phone number field not present on page. This is expected for many private accounts; " + "commercial accounts may still support phone numbers." + ) ) - async def update_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None: + async def update_ads(self, ad_cfgs: list[tuple[str, Ad, dict[str, Any]]]) -> None: """ Updates a list of ads. The list gets filtered, so that only already published ads will be updated. @@ -1372,10 +1398,9 @@ class KleinanzeigenBot(WebScrapingMixin): """ count = 0 - published_ads = json.loads( - (await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"] + published_ads = json.loads((await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"] - for (ad_file, ad_cfg, ad_cfg_orig) in ad_cfgs: + for ad_file, ad_cfg, ad_cfg_orig in ad_cfgs: ad = next((ad for ad in published_ads if ad["id"] == ad_cfg.id), None) if not ad: @@ -1390,25 +1415,25 @@ class KleinanzeigenBot(WebScrapingMixin): await self.publish_ad(ad_file, ad_cfg, ad_cfg_orig, published_ads, AdUpdateStrategy.MODIFY) publish_timeout = self._timeout("publishing_result") - await self.web_await(self.__check_publishing_result, timeout = publish_timeout) + await self.web_await(self.__check_publishing_result, timeout=publish_timeout) LOG.info("############################################") LOG.info("DONE: updated %s", pluralize("ad", count)) LOG.info("############################################") - async def __set_condition(self, condition_value:str) -> None: + async def __set_condition(self, condition_value: str) -> None: try: # Open condition dialog await self.web_click(By.XPATH, '//*[@id="j-post-listing-frontend-conditions"]//button[@aria-haspopup="true"]') except TimeoutError: - LOG.debug("Unable to open condition dialog and select condition [%s]", condition_value, exc_info = True) + LOG.debug("Unable to open condition dialog and select condition [%s]", condition_value, exc_info=True) return try: # Click radio button await self.web_click(By.ID, f"radio-button-{condition_value}") except TimeoutError: - LOG.debug("Unable to select condition [%s]", condition_value, exc_info = True) + LOG.debug("Unable to select condition [%s]", condition_value, exc_info=True) try: # Click accept button @@ -1416,7 +1441,7 @@ class KleinanzeigenBot(WebScrapingMixin): except TimeoutError as ex: raise TimeoutError(_("Unable to close condition dialog!")) from ex - async def __set_category(self, category:str | None, ad_file:str) -> None: + async def __set_category(self, category: str | None, ad_file: str) -> None: # click on something to trigger automatic category detection await self.web_click(By.ID, "pstad-descrptn") @@ -1439,7 +1464,7 @@ class KleinanzeigenBot(WebScrapingMixin): else: ensure(is_category_auto_selected, f"No category specified in [{ad_file}] and automatic category detection failed") - async def __set_special_attributes(self, ad_cfg:Ad) -> None: + async def __set_special_attributes(self, ad_cfg: Ad) -> None: if not ad_cfg.special_attributes: return @@ -1474,7 +1499,7 @@ class KleinanzeigenBot(WebScrapingMixin): raise TimeoutError(_("Failed to set attribute '%s'") % special_attribute_key) from ex try: - elem_id:str = str(special_attr_elem.attrs.id) + elem_id: str = str(special_attr_elem.attrs.id) if special_attr_elem.local_name == "select": LOG.debug(_("Attribute field '%s' seems to be a select..."), special_attribute_key) await self.web_select(By.ID, elem_id, special_attribute_value_str) @@ -1492,27 +1517,26 @@ class KleinanzeigenBot(WebScrapingMixin): raise TimeoutError(_("Failed to set attribute '%s'") % special_attribute_key) from ex LOG.debug("Successfully set attribute field [%s] to [%s]...", special_attribute_key, special_attribute_value_str) - async def __set_shipping(self, ad_cfg:Ad, mode:AdUpdateStrategy = AdUpdateStrategy.REPLACE) -> None: + async def __set_shipping(self, ad_cfg: Ad, mode: AdUpdateStrategy = AdUpdateStrategy.REPLACE) -> None: short_timeout = self._timeout("quick_dom") if ad_cfg.shipping_type == "PICKUP": try: await self.web_click(By.ID, "radio-pickup") except TimeoutError as ex: - LOG.debug(ex, exc_info = True) + LOG.debug(ex, exc_info=True) elif ad_cfg.shipping_options: await self.web_click(By.XPATH, '//button//span[contains(., "Versandmethoden auswählen")]') if mode == AdUpdateStrategy.MODIFY: try: # when "Andere Versandmethoden" is not available, go back and start over new - await self.web_find(By.XPATH, '//dialog//button[contains(., "Andere Versandmethoden")]', timeout = short_timeout) + await self.web_find(By.XPATH, '//dialog//button[contains(., "Andere Versandmethoden")]', timeout=short_timeout) except TimeoutError: await self.web_click(By.XPATH, '//dialog//button[contains(., "Zurück")]') # in some categories we need to go another dialog back try: - await self.web_find(By.XPATH, '//dialog//button[contains(., "Andere Versandmethoden")]', - timeout = short_timeout) + await self.web_find(By.XPATH, '//dialog//button[contains(., "Andere Versandmethoden")]', timeout=short_timeout) except TimeoutError: await self.web_click(By.XPATH, '//dialog//button[contains(., "Zurück")]') @@ -1527,12 +1551,10 @@ class KleinanzeigenBot(WebScrapingMixin): else: try: # no options. only costs. Set custom shipping cost - await self.web_click(By.XPATH, - '//button//span[contains(., "Versandmethoden auswählen")]') + await self.web_click(By.XPATH, '//button//span[contains(., "Versandmethoden auswählen")]') try: # when "Andere Versandmethoden" is not available, then we are already on the individual page - await self.web_click(By.XPATH, - '//dialog//button[contains(., "Andere Versandmethoden")]') + await self.web_click(By.XPATH, '//dialog//button[contains(., "Andere Versandmethoden")]') except TimeoutError: # Dialog option not present; already on the individual shipping page. pass @@ -1540,22 +1562,21 @@ class KleinanzeigenBot(WebScrapingMixin): try: # only click on "Individueller Versand" when "IndividualShippingInput" is not available, otherwise its already checked # (important for mode = UPDATE) - await self.web_find(By.XPATH, - '//input[contains(@placeholder, "Versandkosten (optional)")]', - timeout = short_timeout) + await self.web_find(By.XPATH, '//input[contains(@placeholder, "Versandkosten (optional)")]', timeout=short_timeout) except TimeoutError: # Input not visible yet; click the individual shipping option. await self.web_click(By.XPATH, '//*[contains(@id, "INDIVIDUAL") and contains(@data-testid, "Individueller Versand")]') if ad_cfg.shipping_costs is not None: - await self.web_input(By.XPATH, '//input[contains(@placeholder, "Versandkosten (optional)")]', - str.replace(str(ad_cfg.shipping_costs), ".", ",")) + await self.web_input( + By.XPATH, '//input[contains(@placeholder, "Versandkosten (optional)")]', str.replace(str(ad_cfg.shipping_costs), ".", ",") + ) await self.web_click(By.XPATH, '//dialog//button[contains(., "Fertig")]') except TimeoutError as ex: - LOG.debug(ex, exc_info = True) + LOG.debug(ex, exc_info=True) raise TimeoutError(_("Unable to close shipping dialog!")) from ex - async def __set_shipping_options(self, ad_cfg:Ad, mode:AdUpdateStrategy = AdUpdateStrategy.REPLACE) -> None: + async def __set_shipping_options(self, ad_cfg: Ad, mode: AdUpdateStrategy = AdUpdateStrategy.REPLACE) -> None: if not ad_cfg.shipping_options: return @@ -1575,10 +1596,10 @@ class KleinanzeigenBot(WebScrapingMixin): except KeyError as ex: raise KeyError(f"Unknown shipping option(s), please refer to the documentation/README: {ad_cfg.shipping_options}") from ex - shipping_sizes, shipping_selector, shipping_packages = zip(*mapped_shipping_options, strict = False) + shipping_sizes, shipping_selector, shipping_packages = zip(*mapped_shipping_options, strict=False) try: - shipping_size, = set(shipping_sizes) + (shipping_size,) = set(shipping_sizes) except ValueError as ex: raise ValueError("You can only specify shipping options for one package size!") from ex @@ -1590,8 +1611,7 @@ class KleinanzeigenBot(WebScrapingMixin): if shipping_size_radio_is_checked: # in the same size category all options are preselected, so deselect the unwanted ones unwanted_shipping_packages = [ - package for size, selector, package in shipping_options_mapping.values() - if size == shipping_size and package not in shipping_packages + package for size, selector, package in shipping_options_mapping.values() if size == shipping_size and package not in shipping_packages ] to_be_clicked_shipping_packages = unwanted_shipping_packages else: @@ -1606,10 +1626,7 @@ class KleinanzeigenBot(WebScrapingMixin): LOG.debug("Using MODIFY mode logic for shipping options") # get only correct size - selected_size_shipping_packages = [ - package for size, selector, package in shipping_options_mapping.values() - if size == shipping_size - ] + selected_size_shipping_packages = [package for size, selector, package in shipping_options_mapping.values() if size == shipping_size] LOG.debug("Processing %d packages for size '%s'", len(selected_size_shipping_packages), shipping_size) for shipping_package in selected_size_shipping_packages: @@ -1618,10 +1635,7 @@ class KleinanzeigenBot(WebScrapingMixin): shipping_package_checkbox_is_checked = hasattr(shipping_package_checkbox.attrs, "checked") LOG.debug( - "Package '%s': checked=%s, wanted=%s", - shipping_package, - shipping_package_checkbox_is_checked, - shipping_package in shipping_packages + "Package '%s': checked=%s, wanted=%s", shipping_package, shipping_package_checkbox_is_checked, shipping_package in shipping_packages ) # select wanted packages if not checked already @@ -1636,23 +1650,21 @@ class KleinanzeigenBot(WebScrapingMixin): await self.web_click(By.XPATH, shipping_package_xpath) else: for shipping_package in to_be_clicked_shipping_packages: - await self.web_click( - By.XPATH, - f'//dialog//input[contains(@data-testid, "{shipping_package}")]') + await self.web_click(By.XPATH, f'//dialog//input[contains(@data-testid, "{shipping_package}")]') except TimeoutError as ex: - LOG.debug(ex, exc_info = True) + LOG.debug(ex, exc_info=True) try: # Click apply button await self.web_click(By.XPATH, '//dialog//button[contains(., "Fertig")]') except TimeoutError as ex: raise TimeoutError(_("Unable to close shipping dialog!")) from ex - async def __upload_images(self, ad_cfg:Ad) -> None: + async def __upload_images(self, ad_cfg: Ad) -> None: if not ad_cfg.images: return LOG.info(" -> found %s", pluralize("image", ad_cfg.images)) - image_upload:Element = await self.web_find(By.CSS_SELECTOR, "input[type=file]") + image_upload: Element = await self.web_find(By.CSS_SELECTOR, "input[type=file]") for image in ad_cfg.images: LOG.info(" -> uploading image [%s]", image) @@ -1668,7 +1680,7 @@ class KleinanzeigenBot(WebScrapingMixin): thumbnails = await self.web_find_all( By.CSS_SELECTOR, "ul#j-pictureupload-thumbnails > li:not(.is-placeholder)", - timeout = self._timeout("quick_dom") # Fast timeout for polling + timeout=self._timeout("quick_dom"), # Fast timeout for polling ) current_count = len(thumbnails) if current_count < expected_count: @@ -1679,28 +1691,20 @@ class KleinanzeigenBot(WebScrapingMixin): return False try: - await self.web_await( - check_thumbnails_uploaded, - timeout = self._timeout("image_upload"), - timeout_error_message = _("Image upload timeout exceeded") - ) + await self.web_await(check_thumbnails_uploaded, timeout=self._timeout("image_upload"), timeout_error_message=_("Image upload timeout exceeded")) except TimeoutError as ex: # Get current count for better error message try: thumbnails = await self.web_find_all( - By.CSS_SELECTOR, - "ul#j-pictureupload-thumbnails > li:not(.is-placeholder)", - timeout = self._timeout("quick_dom") + By.CSS_SELECTOR, "ul#j-pictureupload-thumbnails > li:not(.is-placeholder)", timeout=self._timeout("quick_dom") ) current_count = len(thumbnails) except TimeoutError: # Still no thumbnails after full timeout current_count = 0 raise TimeoutError( - _("Not all images were uploaded within timeout. Expected %(expected)d, found %(found)d thumbnails.") % { - "expected": expected_count, - "found": current_count - } + _("Not all images were uploaded within timeout. Expected %(expected)d, found %(found)d thumbnails.") + % {"expected": expected_count, "found": current_count} ) from ex LOG.info(_(" -> all images uploaded successfully")) @@ -1711,7 +1715,7 @@ class KleinanzeigenBot(WebScrapingMixin): This downloads either all, only unsaved (new), or specific ads given by ID. """ - ad_extractor = extract.AdExtractor(self.browser, self.config) + ad_extractor = extract.AdExtractor(self.browser, self.config, self.installation_mode_or_portable) # use relevant download routine if self.ads_selector in {"all", "new"}: # explore ads overview for these two modes @@ -1734,19 +1738,16 @@ class KleinanzeigenBot(WebScrapingMixin): elif self.ads_selector == "new": # download only unsaved ads # check which ads already saved saved_ad_ids = [] - ads = self.load_ads(ignore_inactive = False, exclude_ads_with_id = False) # do not skip because of existing IDs + ads = self.load_ads(ignore_inactive=False, exclude_ads_with_id=False) # do not skip because of existing IDs for ad in ads: saved_ad_id = ad[1].id if saved_ad_id is None: - LOG.debug( - "Skipping saved ad without id (likely unpublished or manually created): %s", - ad[0] - ) + LOG.debug("Skipping saved ad without id (likely unpublished or manually created): %s", ad[0]) continue saved_ad_ids.append(int(saved_ad_id)) # determine ad IDs from links - ad_id_by_url = {url:ad_extractor.extract_ad_id_from_ad_url(url) for url in own_ad_urls} + ad_id_by_url = {url: ad_extractor.extract_ad_id_from_ad_url(url) for url in own_ad_urls} LOG.info("Starting download of not yet downloaded ads...") new_count = 0 @@ -1774,7 +1775,7 @@ class KleinanzeigenBot(WebScrapingMixin): else: LOG.error("The page with the id %d does not exist!", ad_id) - def __get_description(self, ad_cfg:Ad, *, with_affixes:bool) -> str: + def __get_description(self, ad_cfg: Ad, *, with_affixes: bool) -> str: """Get the ad description optionally with prefix and suffix applied. Precedence (highest to lowest): @@ -1797,19 +1798,19 @@ class KleinanzeigenBot(WebScrapingMixin): # Get prefix with precedence prefix = ( # 1. Direct ad-level prefix - ad_cfg.description_prefix if ad_cfg.description_prefix is not None + ad_cfg.description_prefix + if ad_cfg.description_prefix is not None # 2. Global prefix from config - else self.config.ad_defaults.description_prefix - or "" # Default to empty string if all sources are None + else self.config.ad_defaults.description_prefix or "" # Default to empty string if all sources are None ) # Get suffix with precedence suffix = ( # 1. Direct ad-level suffix - ad_cfg.description_suffix if ad_cfg.description_suffix is not None + ad_cfg.description_suffix + if ad_cfg.description_suffix is not None # 2. Global suffix from config - else self.config.ad_defaults.description_suffix - or "" # Default to empty string if all sources are None + else self.config.ad_defaults.description_suffix or "" # Default to empty string if all sources are None ) # Combine the parts and replace @ with (at) @@ -1819,16 +1820,17 @@ class KleinanzeigenBot(WebScrapingMixin): final_description = description_text # Validate length - ensure(len(final_description) <= MAX_DESCRIPTION_LENGTH, - f"Length of ad description including prefix and suffix exceeds {MAX_DESCRIPTION_LENGTH} chars. " - f"Description length: {len(final_description)} chars.") + ensure( + len(final_description) <= MAX_DESCRIPTION_LENGTH, + f"Length of ad description including prefix and suffix exceeds {MAX_DESCRIPTION_LENGTH} chars. Description length: {len(final_description)} chars.", + ) return final_description - def update_content_hashes(self, ads:list[tuple[str, Ad, dict[str, Any]]]) -> None: + def update_content_hashes(self, ads: list[tuple[str, Ad, dict[str, Any]]]) -> None: count = 0 - for (ad_file, ad_cfg, ad_cfg_orig) in ads: + for ad_file, ad_cfg, ad_cfg_orig in ads: LOG.info("Processing %s/%s: '%s' from [%s]...", count + 1, len(ads), ad_cfg.title, ad_file) ad_cfg.update_content_hash() if ad_cfg.content_hash != ad_cfg_orig["content_hash"]: @@ -1840,14 +1842,16 @@ class KleinanzeigenBot(WebScrapingMixin): LOG.info("DONE: Updated [content_hash] in %s", pluralize("ad", count)) LOG.info("############################################") + ############################# # main entry point ############################# -def main(args:list[str]) -> None: +def main(args: list[str]) -> None: if "version" not in args: - print(textwrap.dedent(rf""" + print( + textwrap.dedent(rf""" _ _ _ _ _ _ | | _| | ___(_)_ __ __ _ _ __ _______(_) __ _ ___ _ __ | |__ ___ | |_ | |/ / |/ _ \ | '_ \ / _` | '_ \|_ / _ \ |/ _` |/ _ \ '_ \ ____| '_ \ / _ \| __| @@ -1856,7 +1860,9 @@ def main(args:list[str]) -> None: |___/ https://github.com/Second-Hand-Friends/kleinanzeigen-bot Version: {__version__} - """)[1:], flush = True) # [1:] removes the first empty blank line + """)[1:], + flush=True, + ) # [1:] removes the first empty blank line loggers.configure_console_logging() diff --git a/src/kleinanzeigen_bot/extract.py b/src/kleinanzeigen_bot/extract.py index d3c5ce0..818c628 100644 --- a/src/kleinanzeigen_bot/extract.py +++ b/src/kleinanzeigen_bot/extract.py @@ -15,7 +15,7 @@ from kleinanzeigen_bot.model.ad_model import ContactPartial from .model.ad_model import AdPartial from .model.config_model import Config -from .utils import dicts, files, i18n, loggers, misc, reflect +from .utils import dicts, files, i18n, loggers, misc, reflect, xdg_paths from .utils.web_scraping_mixin import Browser, By, Element, WebScrapingMixin __all__ = [ @@ -33,10 +33,13 @@ class AdExtractor(WebScrapingMixin): Wrapper class for ad extraction that uses an active bot´s browser session to extract specific elements from an ad page. """ - def __init__(self, browser:Browser, config:Config) -> None: + def __init__(self, browser:Browser, config:Config, installation_mode:xdg_paths.InstallationMode = "portable") -> None: super().__init__() self.browser = browser self.config:Config = config + if installation_mode not in {"portable", "xdg"}: + raise ValueError(f"Unsupported installation mode: {installation_mode}") + self.installation_mode:xdg_paths.InstallationMode = installation_mode async def download_ad(self, ad_id:int) -> None: """ @@ -47,26 +50,19 @@ class AdExtractor(WebScrapingMixin): """ # create sub-directory for ad(s) to download (if necessary): - relative_directory = Path("downloaded-ads") - # make sure configured base directory exists (using exist_ok=True to avoid TOCTOU race) - await asyncio.get_running_loop().run_in_executor(None, lambda: relative_directory.mkdir(exist_ok = True)) # noqa: ASYNC240 - LOG.info("Ensured ads directory exists at ./%s.", relative_directory) + download_dir = xdg_paths.get_downloaded_ads_path(self.installation_mode) + LOG.info(_("Using download directory: %s"), download_dir) + # Note: xdg_paths.get_downloaded_ads_path() already creates the directory # Extract ad info and determine final directory path - ad_cfg, final_dir = await self._extract_ad_page_info_with_directory_handling( - relative_directory, ad_id - ) + ad_cfg, final_dir = await self._extract_ad_page_info_with_directory_handling(download_dir, ad_id) # Save the ad configuration file (offload to executor to avoid blocking the event loop) ad_file_path = str(Path(final_dir) / f"ad_{ad_id}.yaml") header_string = ( - "# yaml-language-server: $schema=" - "https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/ad.schema.json" - ) - await asyncio.get_running_loop().run_in_executor( - None, - lambda: dicts.save_dict(ad_file_path, ad_cfg.model_dump(), header = header_string) + "# yaml-language-server: $schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/ad.schema.json" ) + await asyncio.get_running_loop().run_in_executor(None, lambda: dicts.save_dict(ad_file_path, ad_cfg.model_dump(), header = header_string)) @staticmethod def _download_and_save_image_sync(url:str, directory:str, filename_prefix:str, img_nr:int) -> str | None: @@ -114,14 +110,7 @@ class AdExtractor(WebScrapingMixin): if current_img_url is None: continue - img_path = await loop.run_in_executor( - None, - self._download_and_save_image_sync, - str(current_img_url), - directory, - img_fn_prefix, - img_nr - ) + img_path = await loop.run_in_executor(None, self._download_and_save_image_sync, str(current_img_url), directory, img_fn_prefix, img_nr) if img_path: dl_counter += 1 @@ -217,10 +206,7 @@ class AdExtractor(WebScrapingMixin): # Extract references using the CORRECTED selector try: - page_refs:list[str] = [ - str((await self.web_find(By.CSS_SELECTOR, "div h3 a.text-onSurface", parent = li)).attrs["href"]) - for li in list_items - ] + page_refs:list[str] = [str((await self.web_find(By.CSS_SELECTOR, "div h3 a.text-onSurface", parent = li)).attrs["href"]) for li in list_items] refs.extend(page_refs) LOG.info("Successfully extracted %s refs from page %s.", len(page_refs), current_page) except Exception as e: @@ -344,7 +330,7 @@ class AdExtractor(WebScrapingMixin): if prefix and description_text.startswith(prefix.strip()): description_text = description_text[len(prefix.strip()):] if suffix and description_text.endswith(suffix.strip()): - description_text = description_text[:-len(suffix.strip())] + description_text = description_text[: -len(suffix.strip())] info["description"] = description_text.strip() @@ -361,8 +347,7 @@ class AdExtractor(WebScrapingMixin): info["id"] = ad_id try: # try different locations known for creation date element - creation_date = await self.web_text(By.XPATH, - "/html/body/div[1]/div[2]/div/section[2]/section/section/article/div[3]/div[2]/div[2]/div[1]/span") + creation_date = await self.web_text(By.XPATH, "/html/body/div[1]/div[2]/div/section[2]/section/section/article/div[3]/div[2]/div[2]/div[1]/span") except TimeoutError: creation_date = await self.web_text(By.CSS_SELECTOR, "#viewad-extra-info > div:nth-child(1) > span:nth-child(2)") @@ -380,9 +365,7 @@ class AdExtractor(WebScrapingMixin): return ad_cfg - async def _extract_ad_page_info_with_directory_handling( - self, relative_directory:Path, ad_id:int - ) -> tuple[AdPartial, Path]: + async def _extract_ad_page_info_with_directory_handling(self, relative_directory:Path, ad_id:int) -> tuple[AdPartial, Path]: """ Extracts ad information and handles directory creation/renaming. @@ -415,8 +398,7 @@ class AdExtractor(WebScrapingMixin): if await files.exists(temp_dir): if self.config.download.rename_existing_folders: # Rename the old folder to the new name with title - LOG.info("Renaming folder from %s to %s for ad %s...", - temp_dir.name, final_dir.name, ad_id) + LOG.info("Renaming folder from %s to %s for ad %s...", temp_dir.name, final_dir.name, ad_id) LOG.debug("Renaming: %s -> %s", temp_dir, final_dir) await loop.run_in_executor(None, temp_dir.rename, final_dir) else: @@ -471,14 +453,8 @@ class AdExtractor(WebScrapingMixin): category_first_part = await self.web_find(By.CSS_SELECTOR, "a:nth-of-type(2)", parent = category_line) category_second_part = await self.web_find(By.CSS_SELECTOR, "a:nth-of-type(3)", parent = category_line) except TimeoutError as exc: - LOG.error( - "Legacy breadcrumb selectors not found within %.1f seconds (collected ids: %s)", - fallback_timeout, - category_ids - ) - raise TimeoutError( - _("Unable to locate breadcrumb fallback selectors within %(seconds).1f seconds.") % {"seconds": fallback_timeout} - ) from exc + LOG.error("Legacy breadcrumb selectors not found within %.1f seconds (collected ids: %s)", fallback_timeout, category_ids) + raise TimeoutError(_("Unable to locate breadcrumb fallback selectors within %(seconds).1f seconds.") % {"seconds": fallback_timeout}) from exc href_first:str = str(category_first_part.attrs["href"]) href_second:str = str(category_second_part.attrs["href"]) cat_num_first_raw = href_first.rsplit("/", maxsplit = 1)[-1] @@ -553,8 +529,8 @@ class AdExtractor(WebScrapingMixin): # reading shipping option from kleinanzeigen # and find the right one by price shipping_costs = json.loads( - (await self.web_request("https://gateway.kleinanzeigen.de/postad/api/v1/shipping-options?posterType=PRIVATE")) - ["content"])["data"]["shippingOptionsResponse"]["options"] + (await self.web_request("https://gateway.kleinanzeigen.de/postad/api/v1/shipping-options?posterType=PRIVATE"))["content"] + )["data"]["shippingOptionsResponse"]["options"] # map to internal shipping identifiers used by kleinanzeigen-bot shipping_option_mapping = { @@ -566,7 +542,7 @@ class AdExtractor(WebScrapingMixin): "HERMES_001": "Hermes_Päckchen", "HERMES_002": "Hermes_S", "HERMES_003": "Hermes_M", - "HERMES_004": "Hermes_L" + "HERMES_004": "Hermes_L", } # Convert Euro to cents and round to nearest integer diff --git a/src/kleinanzeigen_bot/model/config_model.py b/src/kleinanzeigen_bot/model/config_model.py index 181be94..9ad7438 100644 --- a/src/kleinanzeigen_bot/model/config_model.py +++ b/src/kleinanzeigen_bot/model/config_model.py @@ -15,40 +15,22 @@ from kleinanzeigen_bot.utils import dicts from kleinanzeigen_bot.utils.misc import get_attr from kleinanzeigen_bot.utils.pydantics import ContextualModel -_MAX_PERCENTAGE:Final[int] = 100 +_MAX_PERCENTAGE: Final[int] = 100 class AutoPriceReductionConfig(ContextualModel): - enabled:bool = Field( - default = False, - description = "automatically lower the price of reposted ads" + enabled: bool = Field(default=False, description="automatically lower the price of reposted ads") + strategy: Literal["FIXED", "PERCENTAGE"] | None = Field( + default=None, description="PERCENTAGE reduces by a percentage of the previous price, FIXED reduces by a fixed amount" ) - strategy:Literal["FIXED", "PERCENTAGE"] | None = Field( - default = None, - description = "PERCENTAGE reduces by a percentage of the previous price, FIXED reduces by a fixed amount" - ) - amount:float | None = Field( - default = None, - gt = 0, - description = "magnitude of the reduction; interpreted as percent for PERCENTAGE or currency units for FIXED" - ) - min_price:float | None = Field( - default = None, - ge = 0, - description = "required when enabled is true; minimum price floor (use 0 for no lower bound)" - ) - delay_reposts:int = Field( - default = 0, - ge = 0, - description = "number of reposts to wait before applying the first automatic price reduction" - ) - delay_days:int = Field( - default = 0, - ge = 0, - description = "number of days to wait after publication before applying automatic price reductions" + amount: float | None = Field( + default=None, gt=0, description="magnitude of the reduction; interpreted as percent for PERCENTAGE or currency units for FIXED" ) + min_price: float | None = Field(default=None, ge=0, description="required when enabled is true; minimum price floor (use 0 for no lower bound)") + delay_reposts: int = Field(default=0, ge=0, description="number of reposts to wait before applying the first automatic price reduction") + delay_days: int = Field(default=0, ge=0, description="number of days to wait after publication before applying automatic price reductions") - @model_validator(mode = "after") + @model_validator(mode="after") def _validate_config(self) -> "AutoPriceReductionConfig": if self.enabled: if self.strategy is None: @@ -63,43 +45,38 @@ class AutoPriceReductionConfig(ContextualModel): class ContactDefaults(ContextualModel): - name:str | None = None - street:str | None = None - zipcode:int | str | None = None - location:str | None = Field( - default = None, - description = "city or locality of the listing (can include multiple districts)", - examples = ["Sample Town - District One"] + name: str | None = None + street: str | None = None + zipcode: int | str | None = None + location: str | None = Field( + default=None, description="city or locality of the listing (can include multiple districts)", examples=["Sample Town - District One"] ) - phone:str | None = None + phone: str | None = None @deprecated("Use description_prefix/description_suffix instead") class DescriptionAffixes(ContextualModel): - prefix:str | None = None - suffix:str | None = None + prefix: str | None = None + suffix: str | None = None class AdDefaults(ContextualModel): - active:bool = True - type:Literal["OFFER", "WANTED"] = "OFFER" - description:DescriptionAffixes | None = None - description_prefix:str | None = Field(default = None, description = "prefix for the ad description") - description_suffix:str | None = Field(default = None, description = " suffix for the ad description") - price_type:Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] = "NEGOTIABLE" - auto_price_reduction:AutoPriceReductionConfig = Field( - default_factory = AutoPriceReductionConfig, - description = "automatic price reduction configuration" - ) - shipping_type:Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] = "SHIPPING" - sell_directly:bool = Field(default = False, description = "requires shipping_type SHIPPING to take effect") - images:list[str] | None = Field(default = None) - contact:ContactDefaults = Field(default_factory = ContactDefaults) - republication_interval:int = 7 + active: bool = True + type: Literal["OFFER", "WANTED"] = "OFFER" + description: DescriptionAffixes | None = None + description_prefix: str | None = Field(default=None, description="prefix for the ad description") + description_suffix: str | None = Field(default=None, description=" suffix for the ad description") + price_type: Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] = "NEGOTIABLE" + auto_price_reduction: AutoPriceReductionConfig = Field(default_factory=AutoPriceReductionConfig, description="automatic price reduction configuration") + shipping_type: Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] = "SHIPPING" + sell_directly: bool = Field(default=False, description="requires shipping_type SHIPPING to take effect") + images: list[str] | None = Field(default=None) + contact: ContactDefaults = Field(default_factory=ContactDefaults) + republication_interval: int = 7 - @model_validator(mode = "before") + @model_validator(mode="before") @classmethod - def migrate_legacy_description(cls, values:dict[str, Any]) -> dict[str, Any]: + def migrate_legacy_description(cls, values: dict[str, Any]) -> dict[str, Any]: # Ensure flat prefix/suffix take precedence over deprecated nested "description" description_prefix = values.get("description_prefix") description_suffix = values.get("description_suffix") @@ -114,89 +91,71 @@ class AdDefaults(ContextualModel): class DownloadConfig(ContextualModel): - include_all_matching_shipping_options:bool = Field( - default = False, - description = "if true, all shipping options matching the package size will be included" - ) - excluded_shipping_options:list[str] = Field( - default_factory = list, - description = "list of shipping options to exclude, e.g. ['DHL_2', 'DHL_5']" - ) - folder_name_max_length:int = Field( - default = 100, - ge = 10, - le = 255, - description = "maximum length for folder names when downloading ads (default: 100)" - ) - rename_existing_folders:bool = Field( - default = False, - description = "if true, rename existing folders without titles to include titles (default: false)" - ) + include_all_matching_shipping_options: bool = Field(default=False, description="if true, all shipping options matching the package size will be included") + excluded_shipping_options: list[str] = Field(default_factory=list, description="list of shipping options to exclude, e.g. ['DHL_2', 'DHL_5']") + folder_name_max_length: int = Field(default=100, ge=10, le=255, description="maximum length for folder names when downloading ads (default: 100)") + rename_existing_folders: bool = Field(default=False, description="if true, rename existing folders without titles to include titles (default: false)") class BrowserConfig(ContextualModel): - arguments:list[str] = Field( - default_factory = lambda: ["--user-data-dir=.temp/browser-profile"], - description = "See https://peter.sh/experiments/chromium-command-line-switches/" + arguments: list[str] = Field( + default_factory=list, + description=( + "See https://peter.sh/experiments/chromium-command-line-switches/. " + "Browser profile path is auto-configured based on installation mode (portable/XDG)." + ), ) - binary_location:str | None = Field( - default = None, - description = "path to custom browser executable, if not specified will be looked up on PATH" + binary_location: str | None = Field(default=None, description="path to custom browser executable, if not specified will be looked up on PATH") + extensions: list[str] = Field(default_factory=list, description="a list of .crx extension files to be loaded") + use_private_window: bool = True + user_data_dir: str | None = Field( + default=None, + description=( + "See https://github.com/chromium/chromium/blob/main/docs/user_data_dir.md. " + "If not specified, defaults to XDG cache directory in XDG mode or .temp/browser-profile in portable mode." + ), ) - extensions:list[str] = Field( - default_factory = list, - description = "a list of .crx extension files to be loaded" - ) - use_private_window:bool = True - user_data_dir:str | None = Field( - default = ".temp/browser-profile", - description = "See https://github.com/chromium/chromium/blob/main/docs/user_data_dir.md" - ) - profile_name:str | None = None + profile_name: str | None = None class LoginConfig(ContextualModel): - username:str = Field(..., min_length = 1) - password:str = Field(..., min_length = 1) + username: str = Field(..., min_length=1) + password: str = Field(..., min_length=1) class PublishingConfig(ContextualModel): - delete_old_ads:Literal["BEFORE_PUBLISH", "AFTER_PUBLISH", "NEVER"] | None = "AFTER_PUBLISH" - delete_old_ads_by_title:bool = Field(default = True, description = "only works if delete_old_ads is set to BEFORE_PUBLISH") + delete_old_ads: Literal["BEFORE_PUBLISH", "AFTER_PUBLISH", "NEVER"] | None = "AFTER_PUBLISH" + delete_old_ads_by_title: bool = Field(default=True, description="only works if delete_old_ads is set to BEFORE_PUBLISH") class CaptchaConfig(ContextualModel): - auto_restart:bool = False - restart_delay:str = "6h" + auto_restart: bool = False + restart_delay: str = "6h" class TimeoutConfig(ContextualModel): - multiplier:float = Field( - default = 1.0, - ge = 0.1, - description = "Global multiplier applied to all timeout values." - ) - default:float = Field(default = 5.0, ge = 0.0, description = "Baseline timeout for DOM interactions.") - page_load:float = Field(default = 15.0, ge = 1.0, description = "Page load timeout for web_open.") - captcha_detection:float = Field(default = 2.0, ge = 0.1, description = "Timeout for captcha iframe detection.") - sms_verification:float = Field(default = 4.0, ge = 0.1, description = "Timeout for SMS verification prompts.") - gdpr_prompt:float = Field(default = 10.0, ge = 1.0, description = "Timeout for GDPR/consent dialogs.") - login_detection:float = Field(default = 10.0, ge = 1.0, description = "Timeout for detecting existing login session via DOM elements.") - publishing_result:float = Field(default = 300.0, ge = 10.0, description = "Timeout for publishing result checks.") - publishing_confirmation:float = Field(default = 20.0, ge = 1.0, description = "Timeout for publish confirmation redirect.") - image_upload:float = Field(default = 30.0, ge = 5.0, description = "Timeout for image upload and server-side processing.") - pagination_initial:float = Field(default = 10.0, ge = 1.0, description = "Timeout for initial pagination lookup.") - pagination_follow_up:float = Field(default = 5.0, ge = 1.0, description = "Timeout for subsequent pagination navigation.") - quick_dom:float = Field(default = 2.0, ge = 0.1, description = "Generic short timeout for transient UI.") - update_check:float = Field(default = 10.0, ge = 1.0, description = "Timeout for GitHub update checks.") - chrome_remote_probe:float = Field(default = 2.0, ge = 0.1, description = "Timeout for local remote-debugging probes.") - chrome_remote_debugging:float = Field(default = 5.0, ge = 1.0, description = "Timeout for remote debugging API calls.") - chrome_binary_detection:float = Field(default = 10.0, ge = 1.0, description = "Timeout for chrome --version subprocesses.") - retry_enabled:bool = Field(default = True, description = "Enable built-in retry/backoff for DOM operations.") - retry_max_attempts:int = Field(default = 2, ge = 1, description = "Max retry attempts when retry is enabled.") - retry_backoff_factor:float = Field(default = 1.5, ge = 1.0, description = "Exponential factor applied per retry attempt.") + multiplier: float = Field(default=1.0, ge=0.1, description="Global multiplier applied to all timeout values.") + default: float = Field(default=5.0, ge=0.0, description="Baseline timeout for DOM interactions.") + page_load: float = Field(default=15.0, ge=1.0, description="Page load timeout for web_open.") + captcha_detection: float = Field(default=2.0, ge=0.1, description="Timeout for captcha iframe detection.") + sms_verification: float = Field(default=4.0, ge=0.1, description="Timeout for SMS verification prompts.") + gdpr_prompt: float = Field(default=10.0, ge=1.0, description="Timeout for GDPR/consent dialogs.") + login_detection: float = Field(default=10.0, ge=1.0, description="Timeout for detecting existing login session via DOM elements.") + publishing_result: float = Field(default=300.0, ge=10.0, description="Timeout for publishing result checks.") + publishing_confirmation: float = Field(default=20.0, ge=1.0, description="Timeout for publish confirmation redirect.") + image_upload: float = Field(default=30.0, ge=5.0, description="Timeout for image upload and server-side processing.") + pagination_initial: float = Field(default=10.0, ge=1.0, description="Timeout for initial pagination lookup.") + pagination_follow_up: float = Field(default=5.0, ge=1.0, description="Timeout for subsequent pagination navigation.") + quick_dom: float = Field(default=2.0, ge=0.1, description="Generic short timeout for transient UI.") + update_check: float = Field(default=10.0, ge=1.0, description="Timeout for GitHub update checks.") + chrome_remote_probe: float = Field(default=2.0, ge=0.1, description="Timeout for local remote-debugging probes.") + chrome_remote_debugging: float = Field(default=5.0, ge=1.0, description="Timeout for remote debugging API calls.") + chrome_binary_detection: float = Field(default=10.0, ge=1.0, description="Timeout for chrome --version subprocesses.") + retry_enabled: bool = Field(default=True, description="Enable built-in retry/backoff for DOM operations.") + retry_max_attempts: int = Field(default=2, ge=1, description="Max retry attempts when retry is enabled.") + retry_backoff_factor: float = Field(default=1.5, ge=1.0, description="Exponential factor applied per retry attempt.") - def resolve(self, key:str = "default", override:float | None = None) -> float: + def resolve(self, key: str = "default", override: float | None = None) -> float: """ Return the base timeout (seconds) for the given key without applying modifiers. """ @@ -212,16 +171,16 @@ class TimeoutConfig(ContextualModel): return float(self.default) - def effective(self, key:str = "default", override:float | None = None, *, attempt:int = 0) -> float: + def effective(self, key: str = "default", override: float | None = None, *, attempt: int = 0) -> float: """ Return the effective timeout (seconds) with multiplier/backoff applied. """ base = self.resolve(key, override) - backoff = self.retry_backoff_factor ** attempt if attempt > 0 else 1.0 + backoff = self.retry_backoff_factor**attempt if attempt > 0 else 1.0 return base * self.multiplier * backoff -def _validate_glob_pattern(v:str) -> str: +def _validate_glob_pattern(v: str) -> str: if not v.strip(): raise ValueError("must be a non-empty, non-blank glob pattern") return v @@ -231,21 +190,20 @@ GlobPattern = Annotated[str, AfterValidator(_validate_glob_pattern)] class Config(ContextualModel): - ad_files:list[GlobPattern] = Field( - default_factory = lambda: ["./**/ad_*.{json,yml,yaml}"], - min_items = 1, - description = """ + ad_files: list[GlobPattern] = Field( + default_factory=lambda: ["./**/ad_*.{json,yml,yaml}"], + min_items=1, + description=""" glob (wildcard) patterns to select ad configuration files if relative paths are specified, then they are relative to this configuration file -""" +""", ) # type: ignore[call-overload] - ad_defaults:AdDefaults = Field( - default_factory = AdDefaults, - description = "Default values for ads, can be overwritten in each ad configuration file" - ) + ad_defaults: AdDefaults = Field(default_factory=AdDefaults, description="Default values for ads, can be overwritten in each ad configuration file") - categories:dict[str, str] = Field(default_factory = dict, description = """ + categories: dict[str, str] = Field( + default_factory=dict, + description=""" additional name to category ID mappings, see default list at https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/src/kleinanzeigen_bot/resources/categories.yaml @@ -253,17 +211,16 @@ Example: categories: Elektronik > Notebooks: 161/278 Jobs > Praktika: 102/125 - """) + """, + ) - download:DownloadConfig = Field(default_factory = DownloadConfig) - publishing:PublishingConfig = Field(default_factory = PublishingConfig) - browser:BrowserConfig = Field(default_factory = BrowserConfig, description = "Browser configuration") - login:LoginConfig = Field(default_factory = LoginConfig.model_construct, description = "Login credentials") - captcha:CaptchaConfig = Field(default_factory = CaptchaConfig) - update_check:UpdateCheckConfig = Field(default_factory = UpdateCheckConfig, description = "Update check configuration") - timeouts:TimeoutConfig = Field(default_factory = TimeoutConfig, description = "Centralized timeout configuration.") + download: DownloadConfig = Field(default_factory=DownloadConfig) + publishing: PublishingConfig = Field(default_factory=PublishingConfig) + browser: BrowserConfig = Field(default_factory=BrowserConfig, description="Browser configuration") + login: LoginConfig = Field(default_factory=LoginConfig.model_construct, description="Login credentials") + captcha: CaptchaConfig = Field(default_factory=CaptchaConfig) + update_check: UpdateCheckConfig = Field(default_factory=UpdateCheckConfig, description="Update check configuration") + timeouts: TimeoutConfig = Field(default_factory=TimeoutConfig, description="Centralized timeout configuration.") - def with_values(self, values:dict[str, Any]) -> Config: - return Config.model_validate( - dicts.apply_defaults(copy.deepcopy(values), defaults = self.model_dump()) - ) + def with_values(self, values: dict[str, Any]) -> Config: + return Config.model_validate(dicts.apply_defaults(copy.deepcopy(values), defaults=self.model_dump())) diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index 10609de..0f0b443 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -112,6 +112,11 @@ kleinanzeigen_bot/__init__.py: " -> FAILED: Timeout while extending ad '%s': %s": " -> FEHLER: Zeitüberschreitung beim Verlängern der Anzeige '%s': %s" " -> FAILED: Could not persist extension for ad '%s': %s": " -> FEHLER: Verlängerung der Anzeige '%s' konnte nicht gespeichert werden: %s" + finalize_installation_mode: + "Config file: %s": "Konfigurationsdatei: %s" + "First run detected, prompting user for installation mode": "Erster Start erkannt, frage Benutzer nach Installationsmodus" + "Installation mode: %s": "Installationsmodus: %s" + publish_ads: "Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..." "Skipping because ad is reserved": "Überspringen, da Anzeige reserviert ist" @@ -240,7 +245,7 @@ kleinanzeigen_bot/__init__.py: kleinanzeigen_bot/extract.py: ################################################# download_ad: - "Ensured ads directory exists at ./%s.": "Verzeichnis [%s] für Anzeige vorhanden." + "Using download directory: %s": "Verwende Download-Verzeichnis: %s" _download_and_save_image_sync: "Failed to download image %s: %s": "Fehler beim Herunterladen des Bildes %s: %s" diff --git a/src/kleinanzeigen_bot/update_checker.py b/src/kleinanzeigen_bot/update_checker.py index e7b7d8b..ad5cebd 100644 --- a/src/kleinanzeigen_bot/update_checker.py +++ b/src/kleinanzeigen_bot/update_checker.py @@ -7,7 +7,6 @@ from __future__ import annotations import logging from datetime import datetime from gettext import gettext as _ -from pathlib import Path from typing import TYPE_CHECKING import colorama @@ -22,6 +21,7 @@ except ImportError: __version__ = "unknown" from kleinanzeigen_bot.model.update_check_state import UpdateCheckState +from kleinanzeigen_bot.utils import xdg_paths logger = logging.getLogger(__name__) @@ -31,15 +31,16 @@ colorama.init() class UpdateChecker: """Checks for updates to the bot.""" - def __init__(self, config:"Config") -> None: + def __init__(self, config: "Config", installation_mode: str | xdg_paths.InstallationMode = "portable") -> None: """Initialize the update checker. Args: config: The bot configuration. + installation_mode: Installation mode (portable/xdg). """ self.config = config - self.state_file = Path(".temp") / "update_check_state.json" - self.state_file.parent.mkdir(exist_ok = True) # Ensure .temp directory exists + self.state_file = xdg_paths.get_update_check_state_path(installation_mode) + # Note: xdg_paths handles directory creation self.state = UpdateCheckState.load(self.state_file) def get_local_version(self) -> str | None: @@ -54,7 +55,7 @@ class UpdateChecker: """Return the effective timeout for HTTP calls.""" return self.config.timeouts.effective("update_check") - def _get_commit_hash(self, version:str) -> str | None: + def _get_commit_hash(self, version: str) -> str | None: """Extract the commit hash from a version string. Args: @@ -67,7 +68,7 @@ class UpdateChecker: return version.split("+")[1] return None - def _resolve_commitish(self, commitish:str) -> tuple[str | None, datetime | None]: + def _resolve_commitish(self, commitish: str) -> tuple[str | None, datetime | None]: """Resolve a commit-ish to a full commit hash and date. Args: @@ -79,7 +80,7 @@ class UpdateChecker: try: response = requests.get( f"https://api.github.com/repos/Second-Hand-Friends/kleinanzeigen-bot/commits/{commitish}", - timeout = self._request_timeout() + timeout=self._request_timeout(), ) response.raise_for_status() data = response.json() @@ -95,7 +96,7 @@ class UpdateChecker: logger.warning(_("Could not resolve commit '%s': %s"), commitish, e) return None, None - def _get_short_commit_hash(self, commit:str) -> str: + def _get_short_commit_hash(self, commit: str) -> str: """Get the short version of a commit hash. Args: @@ -106,7 +107,7 @@ class UpdateChecker: """ return commit[:7] - def _commits_match(self, local_commit:str, release_commit:str) -> bool: + def _commits_match(self, local_commit: str, release_commit: str) -> bool: """Determine whether two commits refer to the same hash. This accounts for short vs. full hashes (e.g. 7 chars vs. 40 chars). @@ -119,7 +120,7 @@ class UpdateChecker: return True return len(release_commit) < len(local_commit) and local_commit.startswith(release_commit) - def check_for_updates(self, *, skip_interval_check:bool = False) -> None: + def check_for_updates(self, *, skip_interval_check: bool = False) -> None: """Check for updates to the bot. Args: @@ -146,24 +147,16 @@ class UpdateChecker: try: if self.config.update_check.channel == "latest": # Use /releases/latest endpoint for stable releases - response = requests.get( - "https://api.github.com/repos/Second-Hand-Friends/kleinanzeigen-bot/releases/latest", - timeout = self._request_timeout() - ) + response = requests.get("https://api.github.com/repos/Second-Hand-Friends/kleinanzeigen-bot/releases/latest", timeout=self._request_timeout()) response.raise_for_status() release = response.json() # Defensive: ensure it's not a prerelease if release.get("prerelease", False): - logger.warning( - _("Latest release from GitHub is a prerelease, but 'latest' channel expects a stable release.") - ) + logger.warning(_("Latest release from GitHub is a prerelease, but 'latest' channel expects a stable release.")) return elif self.config.update_check.channel == "preview": # Use /releases endpoint and select the most recent prerelease - response = requests.get( - "https://api.github.com/repos/Second-Hand-Friends/kleinanzeigen-bot/releases", - timeout = self._request_timeout() - ) + response = requests.get("https://api.github.com/repos/Second-Hand-Friends/kleinanzeigen-bot/releases", timeout=self._request_timeout()) response.raise_for_status() releases = response.json() # Find the most recent prerelease @@ -199,7 +192,7 @@ class UpdateChecker: _("You are on the latest version: %s (compared to %s in channel %s)"), local_version, self._get_short_commit_hash(release_commit), - self.config.update_check.channel + self.config.update_check.channel, ) self.state.update_last_check() self.state.save(self.state_file) @@ -212,7 +205,7 @@ class UpdateChecker: release_commit_date.strftime("%Y-%m-%d %H:%M:%S"), local_version, local_commit_date.strftime("%Y-%m-%d %H:%M:%S"), - self.config.update_check.channel + self.config.update_check.channel, ) if release.get("body"): logger.info(_("Release notes:\n%s"), release["body"]) @@ -227,7 +220,7 @@ class UpdateChecker: self._get_short_commit_hash(local_commit), local_commit_date.strftime("%Y-%m-%d %H:%M:%S"), self._get_short_commit_hash(release_commit), - release_commit_date.strftime("%Y-%m-%d %H:%M:%S") + release_commit_date.strftime("%Y-%m-%d %H:%M:%S"), ) # Update the last check time diff --git a/tests/unit/test_extract.py b/tests/unit/test_extract.py index 3ec4b24..617ff10 100644 --- a/tests/unit/test_extract.py +++ b/tests/unit/test_extract.py @@ -17,29 +17,29 @@ from kleinanzeigen_bot.utils.web_scraping_mixin import Browser, By, Element class _DimensionsDict(TypedDict): - ad_attributes:str + ad_attributes: str class _UniversalAnalyticsOptsDict(TypedDict): - dimensions:_DimensionsDict + dimensions: _DimensionsDict class _BelenConfDict(TypedDict): - universalAnalyticsOpts:_UniversalAnalyticsOptsDict + universalAnalyticsOpts: _UniversalAnalyticsOptsDict -class _SpecialAttributesDict(TypedDict, total = False): - art_s:str - condition_s:str +class _SpecialAttributesDict(TypedDict, total=False): + art_s: str + condition_s: str class _TestCaseDict(TypedDict): # noqa: PYI049 Private TypedDict `...` is never used - belen_conf:_BelenConfDict - expected:_SpecialAttributesDict + belen_conf: _BelenConfDict + expected: _SpecialAttributesDict @pytest.fixture -def test_extractor(browser_mock:MagicMock, test_bot_config:Config) -> AdExtractor: +def test_extractor(browser_mock: MagicMock, test_bot_config: Config) -> AdExtractor: """Provides a fresh AdExtractor instance for testing. Dependencies: @@ -52,24 +52,27 @@ def test_extractor(browser_mock:MagicMock, test_bot_config:Config) -> AdExtracto class TestAdExtractorBasics: """Basic synchronous tests for AdExtractor.""" - 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 AdExtractor""" extractor = AdExtractor(browser_mock, test_bot_config) assert extractor.browser == browser_mock assert extractor.config == test_bot_config - @pytest.mark.parametrize(("url", "expected_id"), [ - ("https://www.kleinanzeigen.de/s-anzeige/test-title/12345678", 12345678), - ("https://www.kleinanzeigen.de/s-anzeige/another-test/98765432", 98765432), - ("https://www.kleinanzeigen.de/s-anzeige/invalid-id/abc", -1), - ("https://www.kleinanzeigen.de/invalid-url", -1), - ]) - def test_extract_ad_id_from_ad_url(self, test_extractor:AdExtractor, url:str, expected_id:int) -> None: + @pytest.mark.parametrize( + ("url", "expected_id"), + [ + ("https://www.kleinanzeigen.de/s-anzeige/test-title/12345678", 12345678), + ("https://www.kleinanzeigen.de/s-anzeige/another-test/98765432", 98765432), + ("https://www.kleinanzeigen.de/s-anzeige/invalid-id/abc", -1), + ("https://www.kleinanzeigen.de/invalid-url", -1), + ], + ) + def test_extract_ad_id_from_ad_url(self, test_extractor: AdExtractor, url: str, expected_id: int) -> None: """Test extraction of ad ID from different URL formats.""" assert test_extractor.extract_ad_id_from_ad_url(url) == expected_id @pytest.mark.asyncio - async def test_path_exists_helper(self, tmp_path:Path) -> None: + async def test_path_exists_helper(self, tmp_path: Path) -> None: """Test files.exists helper function.""" from kleinanzeigen_bot.utils import files # noqa: PLC0415 @@ -86,7 +89,7 @@ class TestAdExtractorBasics: assert await files.exists(str(non_existing)) is False @pytest.mark.asyncio - async def test_path_is_dir_helper(self, tmp_path:Path) -> None: + async def test_path_is_dir_helper(self, tmp_path: Path) -> None: """Test files.is_dir helper function.""" from kleinanzeigen_bot.utils import files # noqa: PLC0415 @@ -109,7 +112,7 @@ class TestAdExtractorBasics: assert await files.is_dir(str(non_existing)) is False @pytest.mark.asyncio - async def test_exists_async_helper(self, tmp_path:Path) -> None: + async def test_exists_async_helper(self, tmp_path: Path) -> None: """Test files.exists async helper function.""" from kleinanzeigen_bot.utils import files # noqa: PLC0415 @@ -125,7 +128,7 @@ class TestAdExtractorBasics: assert await files.exists(str(non_existing)) is False @pytest.mark.asyncio - async def test_isdir_async_helper(self, tmp_path:Path) -> None: + async def test_isdir_async_helper(self, tmp_path: Path) -> None: """Test files.is_dir async helper function.""" from kleinanzeigen_bot.utils import files # noqa: PLC0415 @@ -146,7 +149,7 @@ class TestAdExtractorBasics: assert await files.is_dir(non_existing) is False assert await files.is_dir(str(non_existing)) is False - def test_download_and_save_image_sync_success(self, tmp_path:Path) -> None: + def test_download_and_save_image_sync_success(self, tmp_path: Path) -> None: """Test _download_and_save_image_sync with successful download.""" from unittest.mock import MagicMock, mock_open # noqa: PLC0415 @@ -156,33 +159,24 @@ class TestAdExtractorBasics: # Mock urllib response mock_response = MagicMock() mock_response.info().get_content_type.return_value = "image/jpeg" - mock_response.__enter__ = MagicMock(return_value = mock_response) - mock_response.__exit__ = MagicMock(return_value = False) + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) - with patch("kleinanzeigen_bot.extract.urllib_request.urlopen", return_value = mock_response), \ - patch("kleinanzeigen_bot.extract.open", mock_open()), \ - patch("kleinanzeigen_bot.extract.shutil.copyfileobj"): - - result = AdExtractor._download_and_save_image_sync( - "http://example.com/image.jpg", - str(test_dir), - "test_", - 1 - ) + with ( + patch("kleinanzeigen_bot.extract.urllib_request.urlopen", return_value=mock_response), + patch("kleinanzeigen_bot.extract.open", mock_open()), + patch("kleinanzeigen_bot.extract.shutil.copyfileobj"), + ): + result = AdExtractor._download_and_save_image_sync("http://example.com/image.jpg", str(test_dir), "test_", 1) assert result is not None assert result.endswith((".jpe", ".jpeg", ".jpg")) assert "test_1" in result - def test_download_and_save_image_sync_failure(self, tmp_path:Path) -> None: + def test_download_and_save_image_sync_failure(self, tmp_path: Path) -> None: """Test _download_and_save_image_sync with download failure.""" - with patch("kleinanzeigen_bot.extract.urllib_request.urlopen", side_effect = URLError("Network error")): - result = AdExtractor._download_and_save_image_sync( - "http://example.com/image.jpg", - str(tmp_path), - "test_", - 1 - ) + with patch("kleinanzeigen_bot.extract.urllib_request.urlopen", side_effect=URLError("Network error")): + result = AdExtractor._download_and_save_image_sync("http://example.com/image.jpg", str(tmp_path), "test_", 1) assert result is None @@ -190,29 +184,30 @@ class TestAdExtractorBasics: class TestAdExtractorPricing: """Tests for pricing related functionality.""" - @pytest.mark.parametrize(("price_text", "expected_price", "expected_type"), [ - ("50 €", 50, "FIXED"), - ("1.234 €", 1234, "FIXED"), - ("50 € VB", 50, "NEGOTIABLE"), - ("VB", None, "NEGOTIABLE"), - ("Zu verschenken", None, "GIVE_AWAY"), - ]) + @pytest.mark.parametrize( + ("price_text", "expected_price", "expected_type"), + [ + ("50 €", 50, "FIXED"), + ("1.234 €", 1234, "FIXED"), + ("50 € VB", 50, "NEGOTIABLE"), + ("VB", None, "NEGOTIABLE"), + ("Zu verschenken", None, "GIVE_AWAY"), + ], + ) @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_pricing_info( - self, test_extractor:AdExtractor, price_text:str, expected_price:int | None, expected_type:str - ) -> None: + async def test_extract_pricing_info(self, test_extractor: AdExtractor, price_text: str, expected_price: int | None, expected_type: str) -> None: """Test price extraction with different formats""" - with patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = price_text): + with patch.object(test_extractor, "web_text", new_callable=AsyncMock, return_value=price_text): price, price_type = await test_extractor._extract_pricing_info_from_ad_page() assert price == expected_price assert price_type == expected_type @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_pricing_info_timeout(self, test_extractor:AdExtractor) -> None: + async def test_extract_pricing_info_timeout(self, test_extractor: AdExtractor) -> None: """Test price extraction when element is not found""" - with patch.object(test_extractor, "web_text", new_callable = AsyncMock, side_effect = TimeoutError): + with patch.object(test_extractor, "web_text", new_callable=AsyncMock, side_effect=TimeoutError): price, price_type = await test_extractor._extract_pricing_info_from_ad_page() assert price is None assert price_type == "NOT_APPLICABLE" @@ -221,30 +216,26 @@ class TestAdExtractorPricing: class TestAdExtractorShipping: """Tests for shipping related functionality.""" - @pytest.mark.parametrize(("shipping_text", "expected_type", "expected_cost"), [ - ("+ Versand ab 2,99 €", "SHIPPING", 2.99), - ("Nur Abholung", "PICKUP", None), - ("Versand möglich", "SHIPPING", None), - ]) + @pytest.mark.parametrize( + ("shipping_text", "expected_type", "expected_cost"), + [ + ("+ Versand ab 2,99 €", "SHIPPING", 2.99), + ("Nur Abholung", "PICKUP", None), + ("Versand möglich", "SHIPPING", None), + ], + ) @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_shipping_info( - self, test_extractor:AdExtractor, shipping_text:str, expected_type:str, expected_cost:float | None - ) -> None: + async def test_extract_shipping_info(self, test_extractor: AdExtractor, shipping_text: str, expected_type: str, expected_cost: float | None) -> None: """Test shipping info extraction with different text formats.""" - with patch.object(test_extractor, "page", MagicMock()), \ - patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = shipping_text), \ - patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request: - + with ( + patch.object(test_extractor, "page", MagicMock()), + patch.object(test_extractor, "web_text", new_callable=AsyncMock, return_value=shipping_text), + patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request, + ): if expected_cost: - shipping_response:dict[str, Any] = { - "data": { - "shippingOptionsResponse": { - "options": [ - {"id": "DHL_001", "priceInEuroCent": int(expected_cost * 100), "packageSize": "SMALL"} - ] - } - } + shipping_response: dict[str, Any] = { + "data": {"shippingOptionsResponse": {"options": [{"id": "DHL_001", "priceInEuroCent": int(expected_cost * 100), "packageSize": "SMALL"}]}} } mock_web_request.return_value = {"content": json.dumps(shipping_response)} @@ -259,24 +250,17 @@ class TestAdExtractorShipping: @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_shipping_info_with_options(self, test_extractor:AdExtractor) -> None: + async def test_extract_shipping_info_with_options(self, test_extractor: AdExtractor) -> None: """Test shipping info extraction with shipping options.""" shipping_response = { - "content": json.dumps({ - "data": { - "shippingOptionsResponse": { - "options": [ - {"id": "DHL_001", "priceInEuroCent": 549, "packageSize": "SMALL"} - ] - } - } - }) + "content": json.dumps({"data": {"shippingOptionsResponse": {"options": [{"id": "DHL_001", "priceInEuroCent": 549, "packageSize": "SMALL"}]}}}) } - with patch.object(test_extractor, "page", MagicMock()), \ - patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = "+ Versand ab 5,49 €"), \ - patch.object(test_extractor, "web_request", new_callable = AsyncMock, return_value = shipping_response): - + with ( + patch.object(test_extractor, "page", MagicMock()), + patch.object(test_extractor, "web_text", new_callable=AsyncMock, return_value="+ Versand ab 5,49 €"), + patch.object(test_extractor, "web_request", new_callable=AsyncMock, return_value=shipping_response), + ): shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page() assert shipping_type == "SHIPPING" @@ -285,29 +269,32 @@ class TestAdExtractorShipping: @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_shipping_info_with_all_matching_options(self, test_extractor:AdExtractor) -> None: + async def test_extract_shipping_info_with_all_matching_options(self, test_extractor: AdExtractor) -> None: """Test shipping info extraction with all matching options enabled.""" shipping_response = { - "content": json.dumps({ - "data": { - "shippingOptionsResponse": { - "options": [ - {"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"}, - {"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"}, - {"id": "DHL_001", "priceInEuroCent": 619, "packageSize": "SMALL"} - ] + "content": json.dumps( + { + "data": { + "shippingOptionsResponse": { + "options": [ + {"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"}, + {"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"}, + {"id": "DHL_001", "priceInEuroCent": 619, "packageSize": "SMALL"}, + ] + } } } - }) + ) } # Enable all matching options in config test_extractor.config.download = DownloadConfig.model_validate({"include_all_matching_shipping_options": True}) - with patch.object(test_extractor, "page", MagicMock()), \ - patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = "+ Versand ab 4,89 €"), \ - patch.object(test_extractor, "web_request", new_callable = AsyncMock, return_value = shipping_response): - + with ( + patch.object(test_extractor, "page", MagicMock()), + patch.object(test_extractor, "web_text", new_callable=AsyncMock, return_value="+ Versand ab 4,89 €"), + patch.object(test_extractor, "web_request", new_callable=AsyncMock, return_value=shipping_response), + ): shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page() assert shipping_type == "SHIPPING" @@ -319,32 +306,32 @@ class TestAdExtractorShipping: @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_shipping_info_with_excluded_options(self, test_extractor:AdExtractor) -> None: + async def test_extract_shipping_info_with_excluded_options(self, test_extractor: AdExtractor) -> None: """Test shipping info extraction with excluded options.""" shipping_response = { - "content": json.dumps({ - "data": { - "shippingOptionsResponse": { - "options": [ - {"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"}, - {"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"}, - {"id": "DHL_001", "priceInEuroCent": 619, "packageSize": "SMALL"} - ] + "content": json.dumps( + { + "data": { + "shippingOptionsResponse": { + "options": [ + {"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"}, + {"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"}, + {"id": "DHL_001", "priceInEuroCent": 619, "packageSize": "SMALL"}, + ] + } } } - }) + ) } # Enable all matching options and exclude DHL in config - test_extractor.config.download = DownloadConfig.model_validate({ - "include_all_matching_shipping_options": True, - "excluded_shipping_options": ["DHL_2"] - }) - - with patch.object(test_extractor, "page", MagicMock()), \ - patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = "+ Versand ab 4,89 €"), \ - patch.object(test_extractor, "web_request", new_callable = AsyncMock, return_value = shipping_response): + test_extractor.config.download = DownloadConfig.model_validate({"include_all_matching_shipping_options": True, "excluded_shipping_options": ["DHL_2"]}) + with ( + patch.object(test_extractor, "page", MagicMock()), + patch.object(test_extractor, "web_text", new_callable=AsyncMock, return_value="+ Versand ab 4,89 €"), + patch.object(test_extractor, "web_request", new_callable=AsyncMock, return_value=shipping_response), + ): shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page() assert shipping_type == "SHIPPING" @@ -356,30 +343,31 @@ class TestAdExtractorShipping: @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_shipping_info_with_excluded_matching_option(self, test_extractor:AdExtractor) -> None: + async def test_extract_shipping_info_with_excluded_matching_option(self, test_extractor: AdExtractor) -> None: """Test shipping info extraction when the matching option is excluded.""" shipping_response = { - "content": json.dumps({ - "data": { - "shippingOptionsResponse": { - "options": [ - {"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"}, - {"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"} - ] + "content": json.dumps( + { + "data": { + "shippingOptionsResponse": { + "options": [ + {"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"}, + {"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"}, + ] + } } } - }) + ) } # Exclude the matching option - test_extractor.config.download = DownloadConfig.model_validate({ - "excluded_shipping_options": ["Hermes_Päckchen"] - }) - - with patch.object(test_extractor, "page", MagicMock()), \ - patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = "+ Versand ab 4,89 €"), \ - patch.object(test_extractor, "web_request", new_callable = AsyncMock, return_value = shipping_response): + test_extractor.config.download = DownloadConfig.model_validate({"excluded_shipping_options": ["Hermes_Päckchen"]}) + with ( + patch.object(test_extractor, "page", MagicMock()), + patch.object(test_extractor, "web_text", new_callable=AsyncMock, return_value="+ Versand ab 4,89 €"), + patch.object(test_extractor, "web_request", new_callable=AsyncMock, return_value=shipping_response), + ): shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page() assert shipping_type == "NOT_APPLICABLE" @@ -391,21 +379,22 @@ class TestAdExtractorNavigation: """Tests for navigation related functionality.""" @pytest.mark.asyncio - async def test_navigate_to_ad_page_with_url(self, test_extractor:AdExtractor) -> None: + async def test_navigate_to_ad_page_with_url(self, test_extractor: AdExtractor) -> None: """Test navigation to ad page using a URL.""" page_mock = AsyncMock() page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345" - with patch.object(test_extractor, "page", page_mock), \ - patch.object(test_extractor, "web_open", new_callable = AsyncMock) as mock_web_open, \ - patch.object(test_extractor, "web_find", new_callable = AsyncMock, side_effect = TimeoutError): - + with ( + patch.object(test_extractor, "page", page_mock), + patch.object(test_extractor, "web_open", new_callable=AsyncMock) as mock_web_open, + patch.object(test_extractor, "web_find", new_callable=AsyncMock, side_effect=TimeoutError), + ): result = await test_extractor.navigate_to_ad_page("https://www.kleinanzeigen.de/s-anzeige/test/12345") assert result is True mock_web_open.assert_called_with("https://www.kleinanzeigen.de/s-anzeige/test/12345") @pytest.mark.asyncio - async def test_navigate_to_ad_page_with_id(self, test_extractor:AdExtractor) -> None: + async def test_navigate_to_ad_page_with_id(self, test_extractor: AdExtractor) -> None: """Test navigation to ad page using an ID.""" ad_id = 12345 page_mock = AsyncMock() @@ -413,24 +402,25 @@ class TestAdExtractorNavigation: popup_close_mock = AsyncMock() popup_close_mock.click = AsyncMock() - popup_close_mock.apply = AsyncMock(return_value = True) + popup_close_mock.apply = AsyncMock(return_value=True) - def find_mock(selector_type:By, selector_value:str, **_:Any) -> Element | None: + def find_mock(selector_type: By, selector_value: str, **_: Any) -> Element | None: if selector_type == By.CLASS_NAME and selector_value == "mfp-close": return popup_close_mock return None - with patch.object(test_extractor, "page", page_mock), \ - patch.object(test_extractor, "web_open", new_callable = AsyncMock) as mock_web_open, \ - patch.object(test_extractor, "web_find", new_callable = AsyncMock, side_effect = find_mock): - + with ( + patch.object(test_extractor, "page", page_mock), + patch.object(test_extractor, "web_open", new_callable=AsyncMock) as mock_web_open, + patch.object(test_extractor, "web_find", new_callable=AsyncMock, side_effect=find_mock), + ): result = await test_extractor.navigate_to_ad_page(ad_id) assert result is True mock_web_open.assert_called_with("https://www.kleinanzeigen.de/s-suchanfrage.html?keywords={0}".format(ad_id)) popup_close_mock.click.assert_awaited_once() @pytest.mark.asyncio - async def test_navigate_to_ad_page_with_popup(self, test_extractor:AdExtractor) -> None: + async def test_navigate_to_ad_page_with_popup(self, test_extractor: AdExtractor) -> None: """Test navigation to ad page with popup handling.""" page_mock = AsyncMock() page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345" @@ -438,20 +428,21 @@ class TestAdExtractorNavigation: input_mock = AsyncMock() input_mock.clear_input = AsyncMock() input_mock.send_keys = AsyncMock() - input_mock.apply = AsyncMock(return_value = True) - - with patch.object(test_extractor, "page", page_mock), \ - patch.object(test_extractor, "web_open", new_callable = AsyncMock), \ - patch.object(test_extractor, "web_find", new_callable = AsyncMock, return_value = input_mock), \ - patch.object(test_extractor, "web_click", new_callable = AsyncMock) as mock_web_click, \ - patch.object(test_extractor, "web_check", new_callable = AsyncMock, return_value = True): + input_mock.apply = AsyncMock(return_value=True) + with ( + patch.object(test_extractor, "page", page_mock), + patch.object(test_extractor, "web_open", new_callable=AsyncMock), + patch.object(test_extractor, "web_find", new_callable=AsyncMock, return_value=input_mock), + patch.object(test_extractor, "web_click", new_callable=AsyncMock) as mock_web_click, + patch.object(test_extractor, "web_check", new_callable=AsyncMock, return_value=True), + ): result = await test_extractor.navigate_to_ad_page(12345) assert result is True mock_web_click.assert_called_with(By.CLASS_NAME, "mfp-close") @pytest.mark.asyncio - async def test_navigate_to_ad_page_invalid_id(self, test_extractor:AdExtractor) -> None: + async def test_navigate_to_ad_page_invalid_id(self, test_extractor: AdExtractor) -> None: """Test navigation to ad page with invalid ID.""" page_mock = AsyncMock() page_mock.url = "https://www.kleinanzeigen.de/s-suchen.html?k0" @@ -459,26 +450,28 @@ class TestAdExtractorNavigation: input_mock = AsyncMock() input_mock.clear_input = AsyncMock() input_mock.send_keys = AsyncMock() - input_mock.apply = AsyncMock(return_value = True) + input_mock.apply = AsyncMock(return_value=True) input_mock.attrs = {} - with patch.object(test_extractor, "page", page_mock), \ - patch.object(test_extractor, "web_open", new_callable = AsyncMock), \ - patch.object(test_extractor, "web_find", new_callable = AsyncMock, return_value = input_mock): - + with ( + patch.object(test_extractor, "page", page_mock), + patch.object(test_extractor, "web_open", new_callable=AsyncMock), + patch.object(test_extractor, "web_find", new_callable=AsyncMock, return_value=input_mock), + ): result = await test_extractor.navigate_to_ad_page(99999) assert result is False @pytest.mark.asyncio - async def test_extract_own_ads_urls(self, test_extractor:AdExtractor) -> None: + async def test_extract_own_ads_urls(self, test_extractor: AdExtractor) -> None: """Test extraction of own ads URLs - basic test.""" - with patch.object(test_extractor, "web_open", new_callable = AsyncMock), \ - patch.object(test_extractor, "web_sleep", new_callable = AsyncMock), \ - patch.object(test_extractor, "web_find", new_callable = AsyncMock) as mock_web_find, \ - patch.object(test_extractor, "web_find_all", new_callable = AsyncMock) as mock_web_find_all, \ - patch.object(test_extractor, "web_scroll_page_down", new_callable = AsyncMock), \ - patch.object(test_extractor, "web_execute", new_callable = AsyncMock): - + with ( + patch.object(test_extractor, "web_open", new_callable=AsyncMock), + patch.object(test_extractor, "web_sleep", new_callable=AsyncMock), + patch.object(test_extractor, "web_find", new_callable=AsyncMock) as mock_web_find, + patch.object(test_extractor, "web_find_all", new_callable=AsyncMock) as mock_web_find_all, + patch.object(test_extractor, "web_scroll_page_down", new_callable=AsyncMock), + patch.object(test_extractor, "web_execute", new_callable=AsyncMock), + ): # --- Setup mock objects for DOM elements --- # Mocks needed for the actual execution flow ad_list_container_mock = MagicMock() @@ -498,18 +491,18 @@ class TestAdExtractorNavigation: # 3. Find for ad list container (inside loop) # 4. Find for the link (inside list comprehension) mock_web_find.side_effect = [ - ad_list_container_mock, # Call 1: find #my-manageitems-adlist (before loop) + ad_list_container_mock, # Call 1: find #my-manageitems-adlist (before loop) pagination_section_mock, # Call 2: find .Pagination - ad_list_container_mock, # Call 3: find #my-manageitems-adlist (inside loop) - link_mock # Call 4: find 'div.manageitems-item-ad h3 a.text-onSurface' + ad_list_container_mock, # Call 3: find #my-manageitems-adlist (inside loop) + link_mock, # Call 4: find 'div.manageitems-item-ad h3 a.text-onSurface' # Add more mocks here if the pagination navigation logic calls web_find again ] # 1. Find all 'Nächste' buttons (pagination check) - Return empty list for single page test case # 2. Find all '.cardbox' elements (inside loop) mock_web_find_all.side_effect = [ - [], # Call 1: find 'button[aria-label="Nächste"]' -> No next button = single page - [cardbox_mock] # Call 2: find .cardbox -> One ad item + [], # Call 1: find 'button[aria-label="Nächste"]' -> No next button = single page + [cardbox_mock], # Call 2: find .cardbox -> One ad item # Add more mocks here if pagination navigation calls web_find_all ] @@ -520,27 +513,33 @@ class TestAdExtractorNavigation: assert refs == ["/s-anzeige/test/12345"] # Now it should match # Optional: Verify calls were made as expected - mock_web_find.assert_has_calls([ - call(By.ID, "my-manageitems-adlist"), - call(By.CSS_SELECTOR, ".Pagination", timeout = 10), - call(By.ID, "my-manageitems-adlist"), - call(By.CSS_SELECTOR, "div h3 a.text-onSurface", parent = cardbox_mock), - ], any_order = False) # Check order if important + mock_web_find.assert_has_calls( + [ + call(By.ID, "my-manageitems-adlist"), + call(By.CSS_SELECTOR, ".Pagination", timeout=10), + call(By.ID, "my-manageitems-adlist"), + call(By.CSS_SELECTOR, "div h3 a.text-onSurface", parent=cardbox_mock), + ], + any_order=False, + ) # Check order if important - mock_web_find_all.assert_has_calls([ - call(By.CSS_SELECTOR, 'button[aria-label="Nächste"]', parent = pagination_section_mock), - call(By.CLASS_NAME, "cardbox", parent = ad_list_container_mock), - ], any_order = False) + mock_web_find_all.assert_has_calls( + [ + call(By.CSS_SELECTOR, 'button[aria-label="Nächste"]', parent=pagination_section_mock), + call(By.CLASS_NAME, "cardbox", parent=ad_list_container_mock), + ], + any_order=False, + ) @pytest.mark.asyncio - async def test_extract_own_ads_urls_paginates_with_enabled_next_button(self, test_extractor:AdExtractor) -> None: + async def test_extract_own_ads_urls_paginates_with_enabled_next_button(self, test_extractor: AdExtractor) -> None: """Ensure the paginator clicks the first enabled next button and advances.""" ad_list_container_mock = MagicMock() pagination_section_mock = MagicMock() cardbox_page_one = MagicMock() cardbox_page_two = MagicMock() - link_page_one = MagicMock(attrs = {"href": "/s-anzeige/page-one/111"}) - link_page_two = MagicMock(attrs = {"href": "/s-anzeige/page-two/222"}) + link_page_one = MagicMock(attrs={"href": "/s-anzeige/page-one/111"}) + link_page_two = MagicMock(attrs={"href": "/s-anzeige/page-two/222"}) next_button_enabled = AsyncMock() next_button_enabled.attrs = {} @@ -551,8 +550,7 @@ class TestAdExtractorNavigation: next_button_call = {"count": 0} cardbox_call = {"count": 0} - async def fake_web_find(selector_type:By, selector_value:str, *, parent:Element | None = None, - timeout:int | float | None = None) -> Element: + async def fake_web_find(selector_type: By, selector_value: str, *, parent: Element | None = None, timeout: int | float | None = None) -> Element: if selector_type == By.ID and selector_value == "my-manageitems-adlist": return ad_list_container_mock if selector_type == By.CSS_SELECTOR and selector_value == ".Pagination": @@ -561,8 +559,9 @@ class TestAdExtractorNavigation: return link_queue.pop(0) raise AssertionError(f"Unexpected selector {selector_type} {selector_value}") - async def fake_web_find_all(selector_type:By, selector_value:str, *, parent:Element | None = None, - timeout:int | float | None = None) -> list[Element]: + async def fake_web_find_all( + selector_type: By, selector_value: str, *, parent: Element | None = None, timeout: int | float | None = None + ) -> list[Element]: if selector_type == By.CSS_SELECTOR and selector_value == 'button[aria-label="Nächste"]': next_button_call["count"] += 1 if next_button_call["count"] == 1: @@ -575,12 +574,13 @@ class TestAdExtractorNavigation: return [cardbox_page_one] if cardbox_call["count"] == 1 else [cardbox_page_two] raise AssertionError(f"Unexpected find_all selector {selector_type} {selector_value}") - with patch.object(test_extractor, "web_open", new_callable = AsyncMock), \ - patch.object(test_extractor, "web_scroll_page_down", new_callable = AsyncMock), \ - patch.object(test_extractor, "web_sleep", new_callable = AsyncMock), \ - patch.object(test_extractor, "web_find", new_callable = AsyncMock, side_effect = fake_web_find), \ - patch.object(test_extractor, "web_find_all", new_callable = AsyncMock, side_effect = fake_web_find_all): - + with ( + patch.object(test_extractor, "web_open", new_callable=AsyncMock), + patch.object(test_extractor, "web_scroll_page_down", new_callable=AsyncMock), + patch.object(test_extractor, "web_sleep", new_callable=AsyncMock), + patch.object(test_extractor, "web_find", new_callable=AsyncMock, side_effect=fake_web_find), + patch.object(test_extractor, "web_find_all", new_callable=AsyncMock, side_effect=fake_web_find_all), + ): refs = await test_extractor.extract_own_ads_urls() assert refs == ["/s-anzeige/page-one/111", "/s-anzeige/page-two/222"] @@ -589,20 +589,18 @@ class TestAdExtractorNavigation: class TestAdExtractorContent: """Tests for content extraction functionality.""" + # pylint: disable=protected-access @pytest.fixture def extractor_with_config(self) -> AdExtractor: """Create extractor with specific config for testing prefix/suffix handling.""" - browser_mock = MagicMock(spec = Browser) + browser_mock = MagicMock(spec=Browser) return AdExtractor(browser_mock, Config()) # Empty config, will be overridden in tests @pytest.mark.asyncio async def test_extract_description_with_affixes( - self, - test_extractor:AdExtractor, - description_test_cases:list[tuple[dict[str, Any], str, str]], - test_bot_config:Config + self, test_extractor: AdExtractor, description_test_cases: list[tuple[dict[str, Any], str, str]], test_bot_config: Config ) -> None: """Test extraction of description with various prefix/suffix configurations.""" # Mock the page @@ -613,63 +611,52 @@ class TestAdExtractorContent: for config, raw_description, _expected_description in description_test_cases: test_extractor.config = test_bot_config.with_values(config) - with patch.multiple(test_extractor, - web_text = AsyncMock(side_effect = [ - "Test Title", # Title - raw_description, # Raw description (without affixes) - "03.02.2025" # Creation date - ]), - web_execute = AsyncMock(return_value = { - "universalAnalyticsOpts": { - "dimensions": { - "l3_category_id": "", - "ad_attributes": "" - } - } - }), - _extract_category_from_ad_page = AsyncMock(return_value = "160"), - _extract_special_attributes_from_ad_page = AsyncMock(return_value = {}), - _extract_pricing_info_from_ad_page = AsyncMock(return_value = (None, "NOT_APPLICABLE")), - _extract_shipping_info_from_ad_page = AsyncMock(return_value = ("NOT_APPLICABLE", None, None)), - _extract_sell_directly_from_ad_page = AsyncMock(return_value = False), - _download_images_from_ad_page = AsyncMock(return_value = []), - _extract_contact_from_ad_page = AsyncMock(return_value = {}) + with patch.multiple( + test_extractor, + web_text=AsyncMock( + side_effect=[ + "Test Title", # Title + raw_description, # Raw description (without affixes) + "03.02.2025", # Creation date + ] + ), + web_execute=AsyncMock(return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}}), + _extract_category_from_ad_page=AsyncMock(return_value="160"), + _extract_special_attributes_from_ad_page=AsyncMock(return_value={}), + _extract_pricing_info_from_ad_page=AsyncMock(return_value=(None, "NOT_APPLICABLE")), + _extract_shipping_info_from_ad_page=AsyncMock(return_value=("NOT_APPLICABLE", None, None)), + _extract_sell_directly_from_ad_page=AsyncMock(return_value=False), + _download_images_from_ad_page=AsyncMock(return_value=[]), + _extract_contact_from_ad_page=AsyncMock(return_value={}), ): info = await test_extractor._extract_ad_page_info("/some/dir", 12345) assert info.description == raw_description @pytest.mark.asyncio - async def test_extract_description_with_affixes_timeout( - self, - test_extractor:AdExtractor - ) -> None: + async def test_extract_description_with_affixes_timeout(self, test_extractor: AdExtractor) -> None: """Test handling of timeout when extracting description.""" # Mock the page page_mock = MagicMock() page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345" test_extractor.page = page_mock - with patch.multiple(test_extractor, - web_text = AsyncMock(side_effect = [ - "Test Title", # Title succeeds - TimeoutError("Timeout"), # Description times out - "03.02.2025" # Date succeeds - ]), - web_execute = AsyncMock(return_value = { - "universalAnalyticsOpts": { - "dimensions": { - "l3_category_id": "", - "ad_attributes": "" - } - } - }), - _extract_category_from_ad_page = AsyncMock(return_value = "160"), - _extract_special_attributes_from_ad_page = AsyncMock(return_value = {}), - _extract_pricing_info_from_ad_page = AsyncMock(return_value = (None, "NOT_APPLICABLE")), - _extract_shipping_info_from_ad_page = AsyncMock(return_value = ("NOT_APPLICABLE", None, None)), - _extract_sell_directly_from_ad_page = AsyncMock(return_value = False), - _download_images_from_ad_page = AsyncMock(return_value = []), - _extract_contact_from_ad_page = AsyncMock(return_value = ContactPartial()) + with patch.multiple( + test_extractor, + web_text=AsyncMock( + side_effect=[ + "Test Title", # Title succeeds + TimeoutError("Timeout"), # Description times out + "03.02.2025", # Date succeeds + ] + ), + web_execute=AsyncMock(return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}}), + _extract_category_from_ad_page=AsyncMock(return_value="160"), + _extract_special_attributes_from_ad_page=AsyncMock(return_value={}), + _extract_pricing_info_from_ad_page=AsyncMock(return_value=(None, "NOT_APPLICABLE")), + _extract_shipping_info_from_ad_page=AsyncMock(return_value=("NOT_APPLICABLE", None, None)), + _extract_sell_directly_from_ad_page=AsyncMock(return_value=False), + _download_images_from_ad_page=AsyncMock(return_value=[]), + _extract_contact_from_ad_page=AsyncMock(return_value=ContactPartial()), ): try: info = await test_extractor._extract_ad_page_info("/some/dir", 12345) @@ -679,10 +666,7 @@ class TestAdExtractorContent: pass @pytest.mark.asyncio - async def test_extract_description_with_affixes_no_affixes( - self, - test_extractor:AdExtractor - ) -> None: + async def test_extract_description_with_affixes_no_affixes(self, test_extractor: AdExtractor) -> None: """Test extraction of description without any affixes in config.""" # Mock the page page_mock = MagicMock() @@ -690,33 +674,29 @@ class TestAdExtractorContent: test_extractor.page = page_mock raw_description = "Original Description" - with patch.multiple(test_extractor, - web_text = AsyncMock(side_effect = [ - "Test Title", # Title - raw_description, # Description without affixes - "03.02.2025" # Creation date - ]), - web_execute = AsyncMock(return_value = { - "universalAnalyticsOpts": { - "dimensions": { - "l3_category_id": "", - "ad_attributes": "" - } - } - }), - _extract_category_from_ad_page = AsyncMock(return_value = "160"), - _extract_special_attributes_from_ad_page = AsyncMock(return_value = {}), - _extract_pricing_info_from_ad_page = AsyncMock(return_value = (None, "NOT_APPLICABLE")), - _extract_shipping_info_from_ad_page = AsyncMock(return_value = ("NOT_APPLICABLE", None, None)), - _extract_sell_directly_from_ad_page = AsyncMock(return_value = False), - _download_images_from_ad_page = AsyncMock(return_value = []), - _extract_contact_from_ad_page = AsyncMock(return_value = ContactPartial()) + with patch.multiple( + test_extractor, + web_text=AsyncMock( + side_effect=[ + "Test Title", # Title + raw_description, # Description without affixes + "03.02.2025", # Creation date + ] + ), + web_execute=AsyncMock(return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}}), + _extract_category_from_ad_page=AsyncMock(return_value="160"), + _extract_special_attributes_from_ad_page=AsyncMock(return_value={}), + _extract_pricing_info_from_ad_page=AsyncMock(return_value=(None, "NOT_APPLICABLE")), + _extract_shipping_info_from_ad_page=AsyncMock(return_value=("NOT_APPLICABLE", None, None)), + _extract_sell_directly_from_ad_page=AsyncMock(return_value=False), + _download_images_from_ad_page=AsyncMock(return_value=[]), + _extract_contact_from_ad_page=AsyncMock(return_value=ContactPartial()), ): info = await test_extractor._extract_ad_page_info("/some/dir", 12345) assert info.description == raw_description @pytest.mark.asyncio - async def test_extract_sell_directly(self, test_extractor:AdExtractor) -> None: + async def test_extract_sell_directly(self, test_extractor: AdExtractor) -> None: """Test extraction of sell directly option.""" # Mock the page URL to extract the ad ID test_extractor.page = MagicMock() @@ -724,7 +704,7 @@ class TestAdExtractorContent: # Test when extract_ad_id_from_ad_url returns -1 (invalid URL) test_extractor.page.url = "https://www.kleinanzeigen.de/invalid-url" - with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request: + with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request: result = await test_extractor._extract_sell_directly_from_ad_page() assert result is None @@ -735,14 +715,9 @@ class TestAdExtractorContent: test_extractor.page.url = "https://www.kleinanzeigen.de/s-anzeige/test-ad/123456789" # Test successful extraction with buyNowEligible = true - with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request: + with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request: mock_web_request.return_value = { - "content": json.dumps({ - "ads": [ - {"id": 123456789, "buyNowEligible": True}, - {"id": 987654321, "buyNowEligible": False} - ] - }) + "content": json.dumps({"ads": [{"id": 123456789, "buyNowEligible": True}, {"id": 987654321, "buyNowEligible": False}]}) } result = await test_extractor._extract_sell_directly_from_ad_page() @@ -752,14 +727,9 @@ class TestAdExtractorContent: mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json") # Test successful extraction with buyNowEligible = false - with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request: + with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request: mock_web_request.return_value = { - "content": json.dumps({ - "ads": [ - {"id": 123456789, "buyNowEligible": False}, - {"id": 987654321, "buyNowEligible": True} - ] - }) + "content": json.dumps({"ads": [{"id": 123456789, "buyNowEligible": False}, {"id": 987654321, "buyNowEligible": True}]}) } result = await test_extractor._extract_sell_directly_from_ad_page() @@ -769,14 +739,16 @@ class TestAdExtractorContent: mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json") # Test when buyNowEligible is missing from the current ad - with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request: + with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request: mock_web_request.return_value = { - "content": json.dumps({ - "ads": [ - {"id": 123456789}, # No buyNowEligible field - {"id": 987654321, "buyNowEligible": True} - ] - }) + "content": json.dumps( + { + "ads": [ + {"id": 123456789}, # No buyNowEligible field + {"id": 987654321, "buyNowEligible": True}, + ] + } + ) } result = await test_extractor._extract_sell_directly_from_ad_page() @@ -786,14 +758,8 @@ class TestAdExtractorContent: mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json") # Test when current ad is not found in the ads list - with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request: - mock_web_request.return_value = { - "content": json.dumps({ - "ads": [ - {"id": 987654321, "buyNowEligible": True} - ] - }) - } + with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request: + mock_web_request.return_value = {"content": json.dumps({"ads": [{"id": 987654321, "buyNowEligible": True}]})} result = await test_extractor._extract_sell_directly_from_ad_page() assert result is None @@ -802,7 +768,7 @@ class TestAdExtractorContent: mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json") # Test timeout error - with patch.object(test_extractor, "web_request", new_callable = AsyncMock, side_effect = TimeoutError) as mock_web_request: + with patch.object(test_extractor, "web_request", new_callable=AsyncMock, side_effect=TimeoutError) as mock_web_request: result = await test_extractor._extract_sell_directly_from_ad_page() assert result is None @@ -810,10 +776,8 @@ class TestAdExtractorContent: mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json") # Test JSON decode error - with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request: - mock_web_request.return_value = { - "content": "invalid json" - } + with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request: + mock_web_request.return_value = {"content": "invalid json"} result = await test_extractor._extract_sell_directly_from_ad_page() assert result is None @@ -822,10 +786,8 @@ class TestAdExtractorContent: mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json") # Test when ads list is empty - with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request: - mock_web_request.return_value = { - "content": json.dumps({"ads": []}) - } + with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request: + mock_web_request.return_value = {"content": json.dumps({"ads": []})} result = await test_extractor._extract_sell_directly_from_ad_page() assert result is None @@ -834,14 +796,9 @@ class TestAdExtractorContent: mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json") # Test when buyNowEligible is a non-boolean value (string "true") - with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request: + with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request: mock_web_request.return_value = { - "content": json.dumps({ - "ads": [ - {"id": 123456789, "buyNowEligible": "true"}, - {"id": 987654321, "buyNowEligible": False} - ] - }) + "content": json.dumps({"ads": [{"id": 123456789, "buyNowEligible": "true"}, {"id": 987654321, "buyNowEligible": False}]}) } result = await test_extractor._extract_sell_directly_from_ad_page() @@ -851,14 +808,9 @@ class TestAdExtractorContent: mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json") # Test when buyNowEligible is a non-boolean value (integer 1) - with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request: + with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request: mock_web_request.return_value = { - "content": json.dumps({ - "ads": [ - {"id": 123456789, "buyNowEligible": 1}, - {"id": 987654321, "buyNowEligible": False} - ] - }) + "content": json.dumps({"ads": [{"id": 123456789, "buyNowEligible": 1}, {"id": 987654321, "buyNowEligible": False}]}) } result = await test_extractor._extract_sell_directly_from_ad_page() @@ -868,10 +820,8 @@ class TestAdExtractorContent: mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json") # Test when json_data is not a dict (covers line 622) - with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request: - mock_web_request.return_value = { - "content": json.dumps(["not", "a", "dict"]) - } + with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request: + mock_web_request.return_value = {"content": json.dumps(["not", "a", "dict"])} result = await test_extractor._extract_sell_directly_from_ad_page() assert result is None @@ -880,10 +830,8 @@ class TestAdExtractorContent: mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json") # Test when json_data is a dict but doesn't have "ads" key (covers line 622) - with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request: - mock_web_request.return_value = { - "content": json.dumps({"other_key": "value"}) - } + with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request: + mock_web_request.return_value = {"content": json.dumps({"other_key": "value"})} result = await test_extractor._extract_sell_directly_from_ad_page() assert result is None @@ -892,10 +840,8 @@ class TestAdExtractorContent: mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json") # Test when ads_list is not a list (covers line 624) - with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request: - mock_web_request.return_value = { - "content": json.dumps({"ads": "not a list"}) - } + with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request: + mock_web_request.return_value = {"content": json.dumps({"ads": "not a list"})} result = await test_extractor._extract_sell_directly_from_ad_page() assert result is None @@ -908,21 +854,14 @@ class TestAdExtractorCategory: """Tests for category extraction functionality.""" @pytest.fixture - def extractor(self, test_bot_config:Config) -> AdExtractor: - browser_mock = MagicMock(spec = Browser) - config = test_bot_config.with_values({ - "ad_defaults": { - "description": { - "prefix": "Test Prefix", - "suffix": "Test Suffix" - } - } - }) + def extractor(self, test_bot_config: Config) -> AdExtractor: + browser_mock = MagicMock(spec=Browser) + config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}}) return AdExtractor(browser_mock, config) @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_category(self, extractor:AdExtractor) -> None: + async def test_extract_category(self, extractor: AdExtractor) -> None: """Test category extraction from breadcrumb.""" category_line = MagicMock() first_part = MagicMock() @@ -930,35 +869,37 @@ class TestAdExtractorCategory: second_part = MagicMock() second_part.attrs = {"href": "/s-spielzeug/c23"} - with patch.object(extractor, "web_find", new_callable = AsyncMock, side_effect = [category_line]) as mock_web_find, \ - patch.object(extractor, "web_find_all", new_callable = AsyncMock, return_value = [first_part, second_part]) as mock_web_find_all: - + with ( + patch.object(extractor, "web_find", new_callable=AsyncMock, side_effect=[category_line]) as mock_web_find, + patch.object(extractor, "web_find_all", new_callable=AsyncMock, return_value=[first_part, second_part]) as mock_web_find_all, + ): result = await extractor._extract_category_from_ad_page() assert result == "17/23" mock_web_find.assert_awaited_once_with(By.ID, "vap-brdcrmb") - mock_web_find_all.assert_awaited_once_with(By.CSS_SELECTOR, "a", parent = category_line) + mock_web_find_all.assert_awaited_once_with(By.CSS_SELECTOR, "a", parent=category_line) @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_category_single_identifier(self, extractor:AdExtractor) -> None: + async def test_extract_category_single_identifier(self, extractor: AdExtractor) -> None: """Test category extraction when only a single breadcrumb code exists.""" category_line = MagicMock() first_part = MagicMock() first_part.attrs = {"href": "/s-kleidung/c42"} - with patch.object(extractor, "web_find", new_callable = AsyncMock, side_effect = [category_line]) as mock_web_find, \ - patch.object(extractor, "web_find_all", new_callable = AsyncMock, return_value = [first_part]) as mock_web_find_all: - + with ( + patch.object(extractor, "web_find", new_callable=AsyncMock, side_effect=[category_line]) as mock_web_find, + patch.object(extractor, "web_find_all", new_callable=AsyncMock, return_value=[first_part]) as mock_web_find_all, + ): result = await extractor._extract_category_from_ad_page() assert result == "42/42" mock_web_find.assert_awaited_once_with(By.ID, "vap-brdcrmb") - mock_web_find_all.assert_awaited_once_with(By.CSS_SELECTOR, "a", parent = category_line) + mock_web_find_all.assert_awaited_once_with(By.CSS_SELECTOR, "a", parent=category_line) @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_category_fallback_to_legacy_selectors(self, extractor:AdExtractor, caplog:pytest.LogCaptureFixture) -> None: + async def test_extract_category_fallback_to_legacy_selectors(self, extractor: AdExtractor, caplog: pytest.LogCaptureFixture) -> None: """Test category extraction when breadcrumb links are not available and legacy selectors are used.""" category_line = MagicMock() first_part = MagicMock() @@ -968,67 +909,58 @@ class TestAdExtractorCategory: caplog.set_level("DEBUG") expected_message = _("Falling back to legacy breadcrumb selectors; collected ids: %s") % [] - with patch.object(extractor, "web_find", new_callable = AsyncMock) as mock_web_find, \ - patch.object(extractor, "web_find_all", new_callable = AsyncMock, side_effect = TimeoutError) as mock_web_find_all: - - mock_web_find.side_effect = [ - category_line, - first_part, - second_part - ] + with ( + patch.object(extractor, "web_find", new_callable=AsyncMock) as mock_web_find, + patch.object(extractor, "web_find_all", new_callable=AsyncMock, side_effect=TimeoutError) as mock_web_find_all, + ): + mock_web_find.side_effect = [category_line, first_part, second_part] result = await extractor._extract_category_from_ad_page() assert result == "12345/67890" assert sum(1 for record in caplog.records if record.message == expected_message) == 1 mock_web_find.assert_any_call(By.ID, "vap-brdcrmb") - mock_web_find.assert_any_call(By.CSS_SELECTOR, "a:nth-of-type(2)", parent = category_line) - mock_web_find.assert_any_call(By.CSS_SELECTOR, "a:nth-of-type(3)", parent = category_line) - mock_web_find_all.assert_awaited_once_with(By.CSS_SELECTOR, "a", parent = category_line) + mock_web_find.assert_any_call(By.CSS_SELECTOR, "a:nth-of-type(2)", parent=category_line) + mock_web_find.assert_any_call(By.CSS_SELECTOR, "a:nth-of-type(3)", parent=category_line) + mock_web_find_all.assert_awaited_once_with(By.CSS_SELECTOR, "a", parent=category_line) @pytest.mark.asyncio - async def test_extract_category_legacy_selectors_timeout(self, extractor:AdExtractor, caplog:pytest.LogCaptureFixture) -> None: + async def test_extract_category_legacy_selectors_timeout(self, extractor: AdExtractor, caplog: pytest.LogCaptureFixture) -> None: """Ensure fallback timeout logs the error and re-raises with translated message.""" category_line = MagicMock() - async def fake_web_find(selector_type:By, selector_value:str, *, parent:Element | None = None, - timeout:int | float | None = None) -> Element: + async def fake_web_find(selector_type: By, selector_value: str, *, parent: Element | None = None, timeout: int | float | None = None) -> Element: if selector_type == By.ID and selector_value == "vap-brdcrmb": return category_line raise TimeoutError("legacy selectors missing") - with patch.object(extractor, "web_find", new_callable = AsyncMock, side_effect = fake_web_find), \ - patch.object(extractor, "web_find_all", new_callable = AsyncMock, side_effect = TimeoutError), \ - caplog.at_level("ERROR"), pytest.raises(TimeoutError, match = "Unable to locate breadcrumb fallback selectors"): + with ( + patch.object(extractor, "web_find", new_callable=AsyncMock, side_effect=fake_web_find), + patch.object(extractor, "web_find_all", new_callable=AsyncMock, side_effect=TimeoutError), + caplog.at_level("ERROR"), + pytest.raises(TimeoutError, match="Unable to locate breadcrumb fallback selectors"), + ): await extractor._extract_category_from_ad_page() assert any("Legacy breadcrumb selectors not found" in record.message for record in caplog.records) @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_special_attributes_empty(self, extractor:AdExtractor) -> None: + async def test_extract_special_attributes_empty(self, extractor: AdExtractor) -> None: """Test extraction of special attributes when empty.""" - with patch.object(extractor, "web_execute", new_callable = AsyncMock) as mock_web_execute: - mock_web_execute.return_value = { - "universalAnalyticsOpts": { - "dimensions": { - "ad_attributes": "" - } - } - } + with patch.object(extractor, "web_execute", new_callable=AsyncMock) as mock_web_execute: + mock_web_execute.return_value = {"universalAnalyticsOpts": {"dimensions": {"ad_attributes": ""}}} result = await extractor._extract_special_attributes_from_ad_page(mock_web_execute.return_value) assert result == {} @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_special_attributes_not_empty(self, extractor:AdExtractor) -> None: + async def test_extract_special_attributes_not_empty(self, extractor: AdExtractor) -> None: """Test extraction of special attributes when not empty.""" special_atts = { "universalAnalyticsOpts": { - "dimensions": { - "ad_attributes": "versand_s:t|color_s:creme|groesse_s:68|condition_s:alright|type_s:accessoires|art_s:maedchen" - } + "dimensions": {"ad_attributes": "versand_s:t|color_s:creme|groesse_s:68|condition_s:alright|type_s:accessoires|art_s:maedchen"} } } result = await extractor._extract_special_attributes_from_ad_page(special_atts) @@ -1047,9 +979,9 @@ class TestAdExtractorCategory: @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_special_attributes_missing_ad_attributes(self, extractor:AdExtractor) -> None: + async def test_extract_special_attributes_missing_ad_attributes(self, extractor: AdExtractor) -> None: """Test extraction of special attributes when ad_attributes key is missing.""" - belen_conf:dict[str, Any] = { + belen_conf: dict[str, Any] = { "universalAnalyticsOpts": { "dimensions": { # ad_attributes key is completely missing @@ -1064,26 +996,20 @@ class TestAdExtractorContact: """Tests for contact information extraction.""" @pytest.fixture - def extractor(self, test_bot_config:Config) -> AdExtractor: - browser_mock = MagicMock(spec = Browser) - config = test_bot_config.with_values({ - "ad_defaults": { - "description": { - "prefix": "Test Prefix", - "suffix": "Test Suffix" - } - } - }) + def extractor(self, test_bot_config: Config) -> AdExtractor: + browser_mock = MagicMock(spec=Browser) + config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}}) return AdExtractor(browser_mock, config) @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_contact_info(self, extractor:AdExtractor) -> None: + async def test_extract_contact_info(self, extractor: AdExtractor) -> None: """Test extraction of contact information.""" - with patch.object(extractor, "page", MagicMock()), \ - patch.object(extractor, "web_text", new_callable = AsyncMock) as mock_web_text, \ - patch.object(extractor, "web_find", new_callable = AsyncMock) as mock_web_find: - + with ( + patch.object(extractor, "page", MagicMock()), + patch.object(extractor, "web_text", new_callable=AsyncMock) as mock_web_text, + patch.object(extractor, "web_find", new_callable=AsyncMock) as mock_web_find, + ): mock_web_text.side_effect = [ "12345 Berlin - Mitte", "Example Street 123,", @@ -1105,29 +1031,26 @@ class TestAdExtractorContact: @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_contact_info_timeout(self, extractor:AdExtractor) -> None: + async def test_extract_contact_info_timeout(self, extractor: AdExtractor) -> None: """Test contact info extraction when elements are not found.""" - with patch.object(extractor, "page", MagicMock()), \ - patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = TimeoutError()), \ - patch.object(extractor, "web_find", new_callable = AsyncMock, side_effect = TimeoutError()), \ - pytest.raises(TimeoutError): - + with ( + patch.object(extractor, "page", MagicMock()), + patch.object(extractor, "web_text", new_callable=AsyncMock, side_effect=TimeoutError()), + patch.object(extractor, "web_find", new_callable=AsyncMock, side_effect=TimeoutError()), + pytest.raises(TimeoutError), + ): await extractor._extract_contact_from_ad_page() @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_contact_info_with_phone(self, extractor:AdExtractor) -> None: + async def test_extract_contact_info_with_phone(self, extractor: AdExtractor) -> None: """Test extraction of contact information including phone number.""" - with patch.object(extractor, "page", MagicMock()), \ - patch.object(extractor, "web_text", new_callable = AsyncMock) as mock_web_text, \ - patch.object(extractor, "web_find", new_callable = AsyncMock) as mock_web_find: - - mock_web_text.side_effect = [ - "12345 Berlin - Mitte", - "Example Street 123,", - "Test User", - "+49(0)1234 567890" - ] + with ( + patch.object(extractor, "page", MagicMock()), + patch.object(extractor, "web_text", new_callable=AsyncMock) as mock_web_text, + patch.object(extractor, "web_find", new_callable=AsyncMock) as mock_web_find, + ): + mock_web_text.side_effect = ["12345 Berlin - Mitte", "Example Street 123,", "Test User", "+49(0)1234 567890"] phone_element = MagicMock() mock_web_find.side_effect = [ @@ -1144,44 +1067,36 @@ class TestAdExtractorDownload: """Tests for download functionality.""" @pytest.fixture - def extractor(self, test_bot_config:Config) -> AdExtractor: - browser_mock = MagicMock(spec = Browser) - config = test_bot_config.with_values({ - "ad_defaults": { - "description": { - "prefix": "Test Prefix", - "suffix": "Test Suffix" - } - } - }) + def extractor(self, test_bot_config: Config) -> AdExtractor: + browser_mock = MagicMock(spec=Browser) + config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}}) return AdExtractor(browser_mock, config) @pytest.mark.asyncio - async def test_download_ad(self, extractor:AdExtractor) -> None: + async def test_download_ad(self, extractor: AdExtractor, tmp_path: Path) -> None: """Test downloading an ad - directory creation and saving ad data.""" - with patch("pathlib.Path.mkdir"), \ - 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: - - # Use Path for OS-agnostic path handling - final_dir = Path("downloaded-ads") / "ad_12345_Test Advertisement Title" - yaml_path = final_dir / "ad_12345.yaml" + # Use tmp_path for OS-agnostic path handling + download_base = tmp_path / "downloaded-ads" + final_dir = download_base / "ad_12345_Test Advertisement Title" + yaml_path = final_dir / "ad_12345.yaml" + with ( + patch("kleinanzeigen_bot.extract.xdg_paths.get_downloaded_ads_path", return_value=download_base), + patch("kleinanzeigen_bot.extract.dicts.save_dict", autospec=True) as mock_save_dict, + patch.object(extractor, "_extract_ad_page_info_with_directory_handling", new_callable=AsyncMock) as mock_extract_with_dir, + ): mock_extract_with_dir.return_value = ( - AdPartial.model_validate({ - "title": "Test Advertisement Title", - "description": "Test Description", - "category": "Dienstleistungen", - "price": 100, - "images": [], - "contact": { - "name": "Test User", - "street": "Test Street 123", - "zipcode": "12345", - "location": "Test City" + AdPartial.model_validate( + { + "title": "Test Advertisement Title", + "description": "Test Description", + "category": "Dienstleistungen", + "price": 100, + "images": [], + "contact": {"name": "Test User", "street": "Test Street 123", "zipcode": "12345", "location": "Test City"}, } - }), - str(final_dir) + ), + str(final_dir), ) await extractor.download_ad(12345) @@ -1198,15 +1113,15 @@ class TestAdExtractorDownload: @pytest.mark.asyncio # pylint: disable=protected-access - async def test_download_images_no_images(self, extractor:AdExtractor) -> None: + async def test_download_images_no_images(self, extractor: AdExtractor) -> None: """Test image download when no images are found.""" - with patch.object(extractor, "web_find", new_callable = AsyncMock, side_effect = TimeoutError): + with patch.object(extractor, "web_find", new_callable=AsyncMock, side_effect=TimeoutError): image_paths = await extractor._download_images_from_ad_page("/some/dir", 12345) assert len(image_paths) == 0 @pytest.mark.asyncio # pylint: disable=protected-access - async def test_download_images_with_none_url(self, extractor:AdExtractor) -> None: + async def test_download_images_with_none_url(self, extractor: AdExtractor) -> None: """Test image download when some images have None as src attribute.""" image_box_mock = MagicMock() @@ -1217,10 +1132,11 @@ class TestAdExtractorDownload: img_without_url = MagicMock() img_without_url.attrs = {"src": None} - with patch.object(extractor, "web_find", new_callable = AsyncMock, return_value = image_box_mock), \ - patch.object(extractor, "web_find_all", new_callable = AsyncMock, return_value = [img_with_url, img_without_url]), \ - patch.object(AdExtractor, "_download_and_save_image_sync", return_value = "/some/dir/ad_12345__img1.jpg"): - + with ( + patch.object(extractor, "web_find", new_callable=AsyncMock, return_value=image_box_mock), + patch.object(extractor, "web_find_all", new_callable=AsyncMock, return_value=[img_with_url, img_without_url]), + patch.object(AdExtractor, "_download_and_save_image_sync", return_value="/some/dir/ad_12345__img1.jpg"), + ): image_paths = await extractor._download_images_from_ad_page("/some/dir", 12345) # Should only download the one valid image (skip the None) @@ -1229,9 +1145,7 @@ class TestAdExtractorDownload: @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_ad_page_info_with_directory_handling_final_dir_exists( - self, extractor:AdExtractor, tmp_path:Path - ) -> None: + async def test_extract_ad_page_info_with_directory_handling_final_dir_exists(self, extractor: AdExtractor, tmp_path: Path) -> None: """Test directory handling when final_dir already exists - it should be deleted.""" base_dir = tmp_path / "downloaded-ads" base_dir.mkdir() @@ -1248,34 +1162,34 @@ class TestAdExtractorDownload: extractor.page = page_mock with ( - patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [ - "Test Title", # Title extraction - "Test Title", # Second title call for full extraction - "Description text", # Description - "03.02.2025" # Creation date - ]), - patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = { - "universalAnalyticsOpts": { - "dimensions": { - "l3_category_id": "", - "ad_attributes": "" - } - } - }), - patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"), - patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}), - patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")), - patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)), - patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False), - patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []), - patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial( - name = "Test", zipcode = "12345", location = "Berlin" - )), + patch.object( + extractor, + "web_text", + new_callable=AsyncMock, + side_effect=[ + "Test Title", # Title extraction + "Test Title", # Second title call for full extraction + "Description text", # Description + "03.02.2025", # Creation date + ], + ), + patch.object( + extractor, + "web_execute", + new_callable=AsyncMock, + return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}}, + ), + patch.object(extractor, "_extract_category_from_ad_page", new_callable=AsyncMock, return_value="160"), + patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable=AsyncMock, return_value={}), + patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable=AsyncMock, return_value=(None, "NOT_APPLICABLE")), + patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable=AsyncMock, return_value=("NOT_APPLICABLE", None, None)), + patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable=AsyncMock, return_value=False), + patch.object(extractor, "_download_images_from_ad_page", new_callable=AsyncMock, return_value=[]), + patch.object( + extractor, "_extract_contact_from_ad_page", new_callable=AsyncMock, return_value=ContactPartial(name="Test", zipcode="12345", location="Berlin") + ), ): - - ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling( - base_dir, 12345 - ) + ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(base_dir, 12345) # Verify the old directory was deleted and recreated assert result_dir == final_dir @@ -1285,9 +1199,7 @@ class TestAdExtractorDownload: @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_ad_page_info_with_directory_handling_rename_enabled( - self, extractor:AdExtractor, tmp_path:Path - ) -> None: + async def test_extract_ad_page_info_with_directory_handling_rename_enabled(self, extractor: AdExtractor, tmp_path: Path) -> None: """Test directory handling when temp_dir exists and rename_existing_folders is True.""" base_dir = tmp_path / "downloaded-ads" base_dir.mkdir() @@ -1307,34 +1219,34 @@ class TestAdExtractorDownload: extractor.page = page_mock with ( - patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [ - "Test Title", # Title extraction - "Test Title", # Second title call for full extraction - "Description text", # Description - "03.02.2025" # Creation date - ]), - patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = { - "universalAnalyticsOpts": { - "dimensions": { - "l3_category_id": "", - "ad_attributes": "" - } - } - }), - patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"), - patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}), - patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")), - patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)), - patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False), - patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []), - patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial( - name = "Test", zipcode = "12345", location = "Berlin" - )), + patch.object( + extractor, + "web_text", + new_callable=AsyncMock, + side_effect=[ + "Test Title", # Title extraction + "Test Title", # Second title call for full extraction + "Description text", # Description + "03.02.2025", # Creation date + ], + ), + patch.object( + extractor, + "web_execute", + new_callable=AsyncMock, + return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}}, + ), + patch.object(extractor, "_extract_category_from_ad_page", new_callable=AsyncMock, return_value="160"), + patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable=AsyncMock, return_value={}), + patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable=AsyncMock, return_value=(None, "NOT_APPLICABLE")), + patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable=AsyncMock, return_value=("NOT_APPLICABLE", None, None)), + patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable=AsyncMock, return_value=False), + patch.object(extractor, "_download_images_from_ad_page", new_callable=AsyncMock, return_value=[]), + patch.object( + extractor, "_extract_contact_from_ad_page", new_callable=AsyncMock, return_value=ContactPartial(name="Test", zipcode="12345", location="Berlin") + ), ): - - ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling( - base_dir, 12345 - ) + ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(base_dir, 12345) # Verify the directory was renamed from temp_dir to final_dir final_dir = base_dir / "ad_12345_Test Title" @@ -1346,9 +1258,7 @@ class TestAdExtractorDownload: @pytest.mark.asyncio # pylint: disable=protected-access - async def test_extract_ad_page_info_with_directory_handling_use_existing( - self, extractor:AdExtractor, tmp_path:Path - ) -> None: + async def test_extract_ad_page_info_with_directory_handling_use_existing(self, extractor: AdExtractor, tmp_path: Path) -> None: """Test directory handling when temp_dir exists and rename_existing_folders is False (default).""" base_dir = tmp_path / "downloaded-ads" base_dir.mkdir() @@ -1368,34 +1278,34 @@ class TestAdExtractorDownload: extractor.page = page_mock with ( - patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [ - "Test Title", # Title extraction - "Test Title", # Second title call for full extraction - "Description text", # Description - "03.02.2025" # Creation date - ]), - patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = { - "universalAnalyticsOpts": { - "dimensions": { - "l3_category_id": "", - "ad_attributes": "" - } - } - }), - patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"), - patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}), - patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")), - patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)), - patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False), - patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []), - patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial( - name = "Test", zipcode = "12345", location = "Berlin" - )), + patch.object( + extractor, + "web_text", + new_callable=AsyncMock, + side_effect=[ + "Test Title", # Title extraction + "Test Title", # Second title call for full extraction + "Description text", # Description + "03.02.2025", # Creation date + ], + ), + patch.object( + extractor, + "web_execute", + new_callable=AsyncMock, + return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}}, + ), + patch.object(extractor, "_extract_category_from_ad_page", new_callable=AsyncMock, return_value="160"), + patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable=AsyncMock, return_value={}), + patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable=AsyncMock, return_value=(None, "NOT_APPLICABLE")), + patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable=AsyncMock, return_value=("NOT_APPLICABLE", None, None)), + patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable=AsyncMock, return_value=False), + patch.object(extractor, "_download_images_from_ad_page", new_callable=AsyncMock, return_value=[]), + patch.object( + extractor, "_extract_contact_from_ad_page", new_callable=AsyncMock, return_value=ContactPartial(name="Test", zipcode="12345", location="Berlin") + ), ): - - ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling( - base_dir, 12345 - ) + ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(base_dir, 12345) # Verify the existing temp_dir was used (not renamed) assert result_dir == temp_dir @@ -1404,7 +1314,7 @@ class TestAdExtractorDownload: assert ad_cfg.title == "Test Title" @pytest.mark.asyncio - async def test_download_ad_with_umlauts_in_title(self, extractor:AdExtractor, tmp_path:Path) -> None: + async def test_download_ad_with_umlauts_in_title(self, extractor: AdExtractor, tmp_path: Path) -> None: """Test cross-platform Unicode handling for ad titles with umlauts (issue #728). Verifies that: @@ -1424,34 +1334,34 @@ class TestAdExtractorDownload: base_dir.mkdir() with ( - patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [ - title_with_umlauts, # Title extraction - title_with_umlauts, # Second title call for full extraction - "Description text", # Description - "03.02.2025" # Creation date - ]), - patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = { - "universalAnalyticsOpts": { - "dimensions": { - "l3_category_id": "", - "ad_attributes": "" - } - } - }), - patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"), - patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}), - patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")), - patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)), - patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False), - patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []), - patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial( - name = "Test", zipcode = "12345", location = "Berlin" - )), + patch.object( + extractor, + "web_text", + new_callable=AsyncMock, + side_effect=[ + title_with_umlauts, # Title extraction + title_with_umlauts, # Second title call for full extraction + "Description text", # Description + "03.02.2025", # Creation date + ], + ), + patch.object( + extractor, + "web_execute", + new_callable=AsyncMock, + return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}}, + ), + patch.object(extractor, "_extract_category_from_ad_page", new_callable=AsyncMock, return_value="160"), + patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable=AsyncMock, return_value={}), + patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable=AsyncMock, return_value=(None, "NOT_APPLICABLE")), + patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable=AsyncMock, return_value=("NOT_APPLICABLE", None, None)), + patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable=AsyncMock, return_value=False), + patch.object(extractor, "_download_images_from_ad_page", new_callable=AsyncMock, return_value=[]), + patch.object( + extractor, "_extract_contact_from_ad_page", new_callable=AsyncMock, return_value=ContactPartial(name="Test", zipcode="12345", location="Berlin") + ), ): - - ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling( - base_dir, 12345 - ) + ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(base_dir, 12345) # Verify directory was created with NFC-normalized name assert result_dir.exists() @@ -1465,12 +1375,11 @@ class TestAdExtractorDownload: from kleinanzeigen_bot.utils import dicts # noqa: PLC0415 header_string = ( - "# yaml-language-server: $schema=" - "https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/ad.schema.json" + "# yaml-language-server: $schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/ad.schema.json" ) # save_dict normalizes path to NFC, matching the NFC directory name - dicts.save_dict(str(ad_file_path), ad_cfg.model_dump(), header = header_string) + dicts.save_dict(str(ad_file_path), ad_cfg.model_dump(), header=header_string) # Verify file was created successfully (no FileNotFoundError) assert ad_file_path.exists() diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index cb93875..474e258 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -57,13 +57,7 @@ def base_ad_config() -> dict[str, Any]: "active": True, "republication_interval": 7, "created_on": None, - "contact": { - "name": "Test User", - "zipcode": "12345", - "location": "Test City", - "street": "", - "phone": "" - } + "contact": {"name": "Test User", "zipcode": "12345", "location": "Test City", "street": "", "phone": ""}, } @@ -96,25 +90,19 @@ def remove_fields(config:dict[str, Any], *fields:str) -> dict[str, Any]: @pytest.fixture def minimal_ad_config(base_ad_config:dict[str, Any]) -> dict[str, Any]: """Provide a minimal ad configuration with only required fields.""" - return remove_fields( - base_ad_config, - "id", - "created_on", - "shipping_options", - "special_attributes", - "contact.street", - "contact.phone" - ) + return remove_fields(base_ad_config, "id", "created_on", "shipping_options", "special_attributes", "contact.street", "contact.phone") @pytest.fixture def mock_config_setup(test_bot:KleinanzeigenBot) -> Generator[None]: """Provide a centralized mock configuration setup for tests. This fixture mocks load_config and other essential configuration-related methods.""" - with patch.object(test_bot, "load_config"), \ - patch.object(test_bot, "create_browser_session", new_callable = AsyncMock), \ - patch.object(test_bot, "login", new_callable = AsyncMock), \ - patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request: + with ( + patch.object(test_bot, "load_config"), + patch.object(test_bot, "create_browser_session", new_callable = AsyncMock), + patch.object(test_bot, "login", new_callable = AsyncMock), + patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, + ): # Mock the web request for published ads mock_request.return_value = {"content": '{"ads": []}'} yield @@ -138,15 +126,64 @@ class TestKleinanzeigenBotInitialization: with patch("kleinanzeigen_bot.__version__", "1.2.3"): assert test_bot.get_version() == "1.2.3" + def test_finalize_installation_mode_skips_help(self, test_bot:KleinanzeigenBot) -> None: + """Ensure finalize_installation_mode returns early for help.""" + test_bot.command = "help" + test_bot.installation_mode = None + test_bot.finalize_installation_mode() + assert test_bot.installation_mode is None + + @pytest.mark.asyncio + @pytest.mark.parametrize("command", ["verify", "update-check", "update-content-hash", "publish", "delete", "download"]) + async def test_run_uses_installation_mode_for_update_checker(self, test_bot:KleinanzeigenBot, command:str) -> None: + """Ensure UpdateChecker is initialized with the detected installation mode.""" + update_checker_calls:list[tuple[Config, str | None]] = [] + + class DummyUpdateChecker: + def __init__(self, config:Config, installation_mode:str | None) -> None: + update_checker_calls.append((config, installation_mode)) + + def check_for_updates(self, *_args:Any, **_kwargs:Any) -> None: + return None + + def set_installation_mode() -> None: + test_bot.installation_mode = "xdg" + + with ( + patch.object(test_bot, "configure_file_logging"), + patch.object(test_bot, "load_config"), + patch.object(test_bot, "load_ads", return_value = []), + patch.object(test_bot, "create_browser_session", new_callable = AsyncMock), + patch.object(test_bot, "login", new_callable = AsyncMock), + patch.object(test_bot, "download_ads", new_callable = AsyncMock), + patch.object(test_bot, "close_browser_session"), + patch.object(test_bot, "finalize_installation_mode", side_effect = set_installation_mode), + patch("kleinanzeigen_bot.UpdateChecker", DummyUpdateChecker), + ): + await test_bot.run(["app", command]) + + assert update_checker_calls == [(test_bot.config, "xdg")] + + @pytest.mark.asyncio + async def test_download_ads_passes_installation_mode(self, test_bot:KleinanzeigenBot) -> None: + """Ensure download_ads wires installation mode into AdExtractor.""" + test_bot.installation_mode = "xdg" + test_bot.ads_selector = "all" + test_bot.browser = MagicMock() + + extractor_mock = MagicMock() + extractor_mock.extract_own_ads_urls = AsyncMock(return_value = []) + + with patch("kleinanzeigen_bot.extract.AdExtractor", return_value = extractor_mock) as mock_extractor: + await test_bot.download_ads() + + mock_extractor.assert_called_once_with(test_bot.browser, test_bot.config, "xdg") + class TestKleinanzeigenBotLogging: """Tests for logging functionality.""" - def test_configure_file_logging_adds_and_removes_handlers( - self, - test_bot:KleinanzeigenBot, - tmp_path:Path - ) -> None: + def test_configure_file_logging_adds_and_removes_handlers(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None: """Ensure file logging registers a handler and cleans it up afterward.""" log_path = tmp_path / "bot.log" test_bot.log_file_path = str(log_path) @@ -178,26 +215,24 @@ class TestKleinanzeigenBotLogging: class TestKleinanzeigenBotCommandLine: """Tests for command line argument parsing.""" - @pytest.mark.parametrize(("args", "expected_command", "expected_selector", "expected_keep_old"), [ - (["publish", "--ads=all"], "publish", "all", False), - (["verify"], "verify", "due", False), - (["download", "--ads=12345"], "download", "12345", False), - (["publish", "--force"], "publish", "all", False), - (["publish", "--keep-old"], "publish", "due", True), - (["publish", "--ads=all", "--keep-old"], "publish", "all", True), - (["download", "--ads=new"], "download", "new", False), - (["publish", "--ads=changed"], "publish", "changed", False), - (["publish", "--ads=changed,due"], "publish", "changed,due", False), - (["publish", "--ads=changed,new"], "publish", "changed,new", False), - (["version"], "version", "due", False), - ]) + @pytest.mark.parametrize( + ("args", "expected_command", "expected_selector", "expected_keep_old"), + [ + (["publish", "--ads=all"], "publish", "all", False), + (["verify"], "verify", "due", False), + (["download", "--ads=12345"], "download", "12345", False), + (["publish", "--force"], "publish", "all", False), + (["publish", "--keep-old"], "publish", "due", True), + (["publish", "--ads=all", "--keep-old"], "publish", "all", True), + (["download", "--ads=new"], "download", "new", False), + (["publish", "--ads=changed"], "publish", "changed", False), + (["publish", "--ads=changed,due"], "publish", "changed,due", False), + (["publish", "--ads=changed,new"], "publish", "changed,new", False), + (["version"], "version", "due", False), + ], + ) def test_parse_args_handles_valid_arguments( - self, - test_bot:KleinanzeigenBot, - args:list[str], - expected_command:str, - expected_selector:str, - expected_keep_old:bool + self, test_bot:KleinanzeigenBot, args:list[str], expected_command:str, expected_selector:str, expected_keep_old:bool ) -> None: """Verify that valid command line arguments are parsed correctly.""" test_bot.parse_args(["dummy"] + args) # Add dummy arg to simulate sys.argv[0] @@ -226,18 +261,11 @@ class TestKleinanzeigenBotCommandLine: assert exc_info.value.code == 2 assert any( record.levelno == logging.ERROR - and ( - "--invalid-option not recognized" in record.getMessage() - or "Option --invalid-option unbekannt" in record.getMessage() - ) + and ("--invalid-option not recognized" in record.getMessage() or "Option --invalid-option unbekannt" in record.getMessage()) for record in caplog.records ) - assert any( - ("--invalid-option not recognized" in m) - or ("Option --invalid-option unbekannt" in m) - for m in caplog.messages - ) + assert any(("--invalid-option not recognized" in m) or ("Option --invalid-option unbekannt" in m) for m in caplog.messages) def test_parse_args_handles_verbose_flag(self, test_bot:KleinanzeigenBot) -> None: """Verify that verbose flag sets correct log level.""" @@ -254,11 +282,7 @@ class TestKleinanzeigenBotCommandLine: class TestKleinanzeigenBotConfiguration: """Tests for configuration loading and validation.""" - def test_load_config_handles_missing_file( - self, - test_bot:KleinanzeigenBot, - test_data_dir:str - ) -> None: + def test_load_config_handles_missing_file(self, test_bot:KleinanzeigenBot, test_data_dir:str) -> None: """Verify that loading a missing config file creates default config. No info log is expected anymore.""" config_path = Path(test_data_dir) / "missing_config.yaml" config_path.unlink(missing_ok = True) @@ -296,10 +320,14 @@ class TestKleinanzeigenBotAuthentication: @pytest.mark.asyncio async def test_is_logged_in_returns_true_with_alternative_element(self, test_bot:KleinanzeigenBot) -> None: """Verify that login check returns true when logged in with alternative element.""" - with patch.object(test_bot, "web_text", side_effect = [ - TimeoutError(), # First try with mr-medium fails - "angemeldet als: dummy_user" # Second try with user-email succeeds - ]): + with patch.object( + test_bot, + "web_text", + side_effect = [ + TimeoutError(), # First try with mr-medium fails + "angemeldet als: dummy_user", # Second try with user-email succeeds + ], + ): assert await test_bot.is_logged_in() is True @pytest.mark.asyncio @@ -311,12 +339,13 @@ class TestKleinanzeigenBotAuthentication: @pytest.mark.asyncio async def test_login_flow_completes_successfully(self, test_bot:KleinanzeigenBot) -> None: """Verify that normal login flow completes successfully.""" - with patch.object(test_bot, "web_open") as mock_open, \ - patch.object(test_bot, "is_logged_in", side_effect = [False, True]) as mock_logged_in, \ - patch.object(test_bot, "web_find", side_effect = TimeoutError), \ - patch.object(test_bot, "web_input") as mock_input, \ - patch.object(test_bot, "web_click") as mock_click: - + with ( + patch.object(test_bot, "web_open") as mock_open, + patch.object(test_bot, "is_logged_in", side_effect = [False, True]) as mock_logged_in, + patch.object(test_bot, "web_find", side_effect = TimeoutError), + patch.object(test_bot, "web_input") as mock_input, + patch.object(test_bot, "web_click") as mock_click, + ): await test_bot.login() mock_open.assert_called() @@ -327,13 +356,14 @@ class TestKleinanzeigenBotAuthentication: @pytest.mark.asyncio async def test_login_flow_handles_captcha(self, test_bot:KleinanzeigenBot) -> None: """Verify that login flow handles captcha correctly.""" - with patch.object(test_bot, "web_open"), \ - patch.object(test_bot, "is_logged_in", side_effect = [False, False, True]), \ - patch.object(test_bot, "web_find") as mock_find, \ - patch.object(test_bot, "web_input") as mock_input, \ - patch.object(test_bot, "web_click") as mock_click, \ - patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput: - + with ( + patch.object(test_bot, "web_open"), + patch.object(test_bot, "is_logged_in", side_effect = [False, False, True]), + patch.object(test_bot, "web_find") as mock_find, + patch.object(test_bot, "web_input") as mock_input, + patch.object(test_bot, "web_click") as mock_click, + patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput, + ): # Mock the sequence of web_find calls: # First login attempt: # 1. Captcha iframe found (in check_and_wait_for_captcha) @@ -366,9 +396,7 @@ class TestKleinanzeigenBotAuthentication: @pytest.mark.asyncio async def test_check_and_wait_for_captcha(self, test_bot:KleinanzeigenBot) -> None: """Verify that captcha detection works correctly.""" - with patch.object(test_bot, "web_find") as mock_find, \ - patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput: - + with patch.object(test_bot, "web_find") as mock_find, patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput: # Test case 1: Captcha found mock_find.return_value = AsyncMock() mock_ainput.return_value = "" @@ -390,10 +418,11 @@ class TestKleinanzeigenBotAuthentication: @pytest.mark.asyncio async def test_fill_login_data_and_send(self, test_bot:KleinanzeigenBot) -> None: """Verify that login form filling works correctly.""" - with patch.object(test_bot, "web_input") as mock_input, \ - patch.object(test_bot, "web_click") as mock_click, \ - patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock) as mock_captcha: - + with ( + patch.object(test_bot, "web_input") as mock_input, + patch.object(test_bot, "web_click") as mock_click, + patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock) as mock_captcha, + ): # Mock successful login form interaction mock_input.return_value = AsyncMock() mock_click.return_value = AsyncMock() @@ -407,10 +436,11 @@ class TestKleinanzeigenBotAuthentication: @pytest.mark.asyncio async def test_handle_after_login_logic(self, test_bot:KleinanzeigenBot) -> None: """Verify that post-login handling works correctly.""" - with patch.object(test_bot, "web_find") as mock_find, \ - patch.object(test_bot, "web_click") as mock_click, \ - patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput: - + with ( + patch.object(test_bot, "web_find") as mock_find, + patch.object(test_bot, "web_click") as mock_click, + patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput, + ): # Test case 1: No special handling needed mock_find.side_effect = [TimeoutError(), TimeoutError()] # No phone verification, no GDPR mock_click.return_value = AsyncMock() @@ -452,8 +482,7 @@ class TestKleinanzeigenBotLocalization: def test_show_help_displays_german_text(self, test_bot:KleinanzeigenBot) -> None: """Verify that help text is displayed in German when language is German.""" - with patch("kleinanzeigen_bot.get_current_locale") as mock_locale, \ - patch("builtins.print") as mock_print: + with patch("kleinanzeigen_bot.get_current_locale") as mock_locale, patch("builtins.print") as mock_print: mock_locale.return_value.language = "de" test_bot.show_help() printed_text = "".join(str(call.args[0]) for call in mock_print.call_args_list) @@ -462,8 +491,7 @@ class TestKleinanzeigenBotLocalization: def test_show_help_displays_english_text(self, test_bot:KleinanzeigenBot) -> None: """Verify that help text is displayed in English when language is English.""" - with patch("kleinanzeigen_bot.get_current_locale") as mock_locale, \ - patch("builtins.print") as mock_print: + with patch("kleinanzeigen_bot.get_current_locale") as mock_locale, patch("builtins.print") as mock_print: mock_locale.return_value.language = "en" test_bot.show_help() printed_text = "".join(str(call.args[0]) for call in mock_print.call_args_list) @@ -493,11 +521,12 @@ class TestKleinanzeigenBotBasics: payload:dict[str, list[Any]] = {"ads": []} ad_cfgs:list[tuple[str, Ad, dict[str, Any]]] = [("ad.yaml", Ad.model_validate(base_ad_config), {})] - with patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps(payload)}) as web_request_mock, \ - patch.object(test_bot, "publish_ad", new_callable = AsyncMock) as publish_ad_mock, \ - patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True) as web_await_mock, \ - patch.object(test_bot, "delete_ad", new_callable = AsyncMock) as delete_ad_mock: - + with ( + patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps(payload)}) as web_request_mock, + patch.object(test_bot, "publish_ad", new_callable = AsyncMock) as publish_ad_mock, + patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True) as web_await_mock, + patch.object(test_bot, "delete_ad", new_callable = AsyncMock) as delete_ad_mock, + ): await test_bot.publish_ads(ad_cfgs) web_request_mock.assert_awaited_once_with(f"{test_bot.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT") @@ -641,11 +670,14 @@ class TestKleinanzeigenBotCommands: async def test_verify_command(self, test_bot:KleinanzeigenBot, tmp_path:Any) -> None: """Test verify command with minimal config.""" config_path = Path(tmp_path) / "config.yaml" - config_path.write_text(""" + config_path.write_text( + """ login: username: test password: test -""", encoding = "utf-8") +""", + encoding = "utf-8", + ) test_bot.config_file_path = str(config_path) await test_bot.run(["script.py", "verify"]) assert test_bot.config.login.username == "test" @@ -735,9 +767,7 @@ categories: ad_file = ad_dir / "test_ad.yaml" # Create a minimal config with empty title to trigger validation - ad_cfg = minimal_ad_config | { - "title": "" - } + ad_cfg = minimal_ad_config | {"title": ""} dicts.save_dict(ad_file, ad_cfg) # Set config file path to tmp_path and use relative path for ad_files @@ -755,9 +785,7 @@ categories: ad_file = ad_dir / "test_ad.yaml" # Create config with invalid price type - ad_cfg = minimal_ad_config | { - "price_type": "INVALID_TYPE" - } + ad_cfg = minimal_ad_config | {"price_type": "INVALID_TYPE"} dicts.save_dict(ad_file, ad_cfg) # Set config file path to tmp_path and use relative path for ad_files @@ -775,9 +803,7 @@ categories: ad_file = ad_dir / "test_ad.yaml" # Create config with invalid shipping type - ad_cfg = minimal_ad_config | { - "shipping_type": "INVALID_TYPE" - } + ad_cfg = minimal_ad_config | {"shipping_type": "INVALID_TYPE"} dicts.save_dict(ad_file, ad_cfg) # Set config file path to tmp_path and use relative path for ad_files @@ -797,7 +823,7 @@ categories: # Create config with price for GIVE_AWAY type ad_cfg = minimal_ad_config | { "price_type": "GIVE_AWAY", - "price": 100 # Price should not be set for GIVE_AWAY + "price": 100, # Price should not be set for GIVE_AWAY } dicts.save_dict(ad_file, ad_cfg) @@ -818,7 +844,7 @@ categories: # Create config with FIXED price type but no price ad_cfg = minimal_ad_config | { "price_type": "FIXED", - "price": None # Missing required price for FIXED type + "price": None, # Missing required price for FIXED type } dicts.save_dict(ad_file, ad_cfg) @@ -841,20 +867,22 @@ class TestKleinanzeigenBotAdDeletion: test_bot.page.sleep = AsyncMock() # Use minimal config since we only need title for deletion by title - ad_cfg = Ad.model_validate(minimal_ad_config | { - "title": "Test Title", - "id": None # Explicitly set id to None for title-based deletion - }) + ad_cfg = Ad.model_validate( + minimal_ad_config + | { + "title": "Test Title", + "id": None, # Explicitly set id to None for title-based deletion + } + ) - published_ads = [ - {"title": "Test Title", "id": "67890"}, - {"title": "Other Title", "id": "11111"} - ] + published_ads = [{"title": "Test Title", "id": "67890"}, {"title": "Other Title", "id": "11111"}] - with patch.object(test_bot, "web_open", new_callable = AsyncMock), \ - patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \ - patch.object(test_bot, "web_click", new_callable = AsyncMock), \ - patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True): + with ( + patch.object(test_bot, "web_open", new_callable = AsyncMock), + patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, + patch.object(test_bot, "web_click", new_callable = AsyncMock), + patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), + ): mock_find.return_value.attrs = {"content": "some-token"} result = await test_bot.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = True) assert result is True @@ -867,19 +895,21 @@ class TestKleinanzeigenBotAdDeletion: test_bot.page.sleep = AsyncMock() # Create config with ID for deletion by ID - ad_cfg = Ad.model_validate(minimal_ad_config | { - "id": "12345" # Fixed: use proper dict key syntax - }) + ad_cfg = Ad.model_validate( + minimal_ad_config + | { + "id": "12345" # Fixed: use proper dict key syntax + } + ) - published_ads = [ - {"title": "Different Title", "id": "12345"}, - {"title": "Other Title", "id": "11111"} - ] + published_ads = [{"title": "Different Title", "id": "12345"}, {"title": "Other Title", "id": "11111"}] - with patch.object(test_bot, "web_open", new_callable = AsyncMock), \ - patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \ - patch.object(test_bot, "web_click", new_callable = AsyncMock), \ - patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True): + with ( + patch.object(test_bot, "web_open", new_callable = AsyncMock), + patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, + patch.object(test_bot, "web_click", new_callable = AsyncMock), + patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), + ): mock_find.return_value.attrs = {"content": "some-token"} result = await test_bot.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = False) assert result is True @@ -892,20 +922,17 @@ class TestKleinanzeigenBotAdDeletion: test_bot.page.sleep = AsyncMock() # Create config with ID for deletion by ID - ad_cfg = Ad.model_validate(minimal_ad_config | { - "id": "12345" - }) + ad_cfg = Ad.model_validate(minimal_ad_config | {"id": "12345"}) - published_ads = [ - {"title": "Different Title", "id": "12345"}, - {"title": "Other Title", "id": "11111"} - ] + published_ads = [{"title": "Different Title", "id": "12345"}, {"title": "Other Title", "id": "11111"}] - with patch.object(test_bot, "web_open", new_callable = AsyncMock), \ - patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \ - patch.object(test_bot, "web_click", new_callable = AsyncMock), \ - patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), \ - patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request: + with ( + patch.object(test_bot, "web_open", new_callable = AsyncMock), + patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, + patch.object(test_bot, "web_click", new_callable = AsyncMock), + patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), + patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, + ): # Mock non-string CSRF token to test str() conversion mock_find.return_value.attrs = {"content": 12345} # Non-string token result = await test_bot.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = False) @@ -923,20 +950,12 @@ class TestKleinanzeigenBotAdRepublication: def test_check_ad_republication_with_changes(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None: """Test that ads with changes are marked for republication.""" # Mock the description config to prevent modification of the description - test_bot.config.ad_defaults = AdDefaults.model_validate({ - "description": { - "prefix": "", - "suffix": "" - } - }) + test_bot.config.ad_defaults = AdDefaults.model_validate({"description": {"prefix": "", "suffix": ""}}) # Create ad config with all necessary fields for republication - ad_cfg = Ad.model_validate(base_ad_config | { - "id": "12345", - "updated_on": "2024-01-01T00:00:01", - "created_on": "2024-01-01T00:00:01", - "description": "Changed description" - }) + ad_cfg = Ad.model_validate( + base_ad_config | {"id": "12345", "updated_on": "2024-01-01T00:00:01", "created_on": "2024-01-01T00:00:01", "description": "Changed description"} + ) # Create a temporary directory and file with tempfile.TemporaryDirectory() as temp_dir: @@ -960,11 +979,7 @@ class TestKleinanzeigenBotAdRepublication: three_days_ago = (current_time - timedelta(days = 3)).isoformat() # Create ad config with timestamps for republication check - ad_cfg = Ad.model_validate(base_ad_config | { - "id": "12345", - "updated_on": three_days_ago, - "created_on": three_days_ago - }) + ad_cfg = Ad.model_validate(base_ad_config | {"id": "12345", "updated_on": three_days_ago, "created_on": three_days_ago}) # Calculate hash before making the copy to ensure they match ad_cfg_orig = ad_cfg.model_dump() @@ -973,8 +988,10 @@ class TestKleinanzeigenBotAdRepublication: # Mock the config to prevent actual file operations test_bot.config.ad_files = ["test.yaml"] - with patch("kleinanzeigen_bot.utils.dicts.load_dict_if_exists", return_value = ad_cfg_orig), \ - patch("kleinanzeigen_bot.utils.dicts.load_dict", return_value = {}): # Mock ad_fields.yaml + with ( + patch("kleinanzeigen_bot.utils.dicts.load_dict_if_exists", return_value = ad_cfg_orig), + patch("kleinanzeigen_bot.utils.dicts.load_dict", return_value = {}), + ): # Mock ad_fields.yaml ads_to_publish = test_bot.load_ads() assert len(ads_to_publish) == 0 # No ads should be marked for republication @@ -991,11 +1008,14 @@ class TestKleinanzeigenBotShippingOptions: test_bot.page.evaluate = AsyncMock() # Create ad config with specific shipping options - ad_cfg = Ad.model_validate(base_ad_config | { - "shipping_options": ["DHL_2", "Hermes_Päckchen"], - "updated_on": "2024-01-01T00:00:00", # Add created_on to prevent KeyError - "created_on": "2024-01-01T00:00:00" # Add updated_on for consistency - }) + ad_cfg = Ad.model_validate( + base_ad_config + | { + "shipping_options": ["DHL_2", "Hermes_Päckchen"], + "updated_on": "2024-01-01T00:00:00", # Add created_on to prevent KeyError + "created_on": "2024-01-01T00:00:00", # Add updated_on for consistency + } + ) # Create the original ad config and published ads list ad_cfg.update_content_hash() # Add content hash to prevent republication @@ -1003,10 +1023,7 @@ class TestKleinanzeigenBotShippingOptions: published_ads:list[dict[str, Any]] = [] # Set up default config values needed for the test - test_bot.config.publishing = PublishingConfig.model_validate({ - "delete_old_ads": "BEFORE_PUBLISH", - "delete_old_ads_by_title": False - }) + test_bot.config.publishing = PublishingConfig.model_validate({"delete_old_ads": "BEFORE_PUBLISH", "delete_old_ads_by_title": False}) # Create temporary file path ad_file = Path(tmp_path) / "test_ad.yaml" @@ -1031,20 +1048,21 @@ class TestKleinanzeigenBotShippingOptions: category_path_elem.apply = AsyncMock(return_value = "Test Category") # Mock the necessary web interaction methods - with patch.object(test_bot, "web_execute", side_effect = mock_web_execute), \ - patch.object(test_bot, "web_click", new_callable = AsyncMock), \ - patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \ - patch.object(test_bot, "web_select", new_callable = AsyncMock), \ - patch.object(test_bot, "web_input", new_callable = AsyncMock), \ - patch.object(test_bot, "web_open", new_callable = AsyncMock), \ - patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \ - patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), \ - patch.object(test_bot, "web_request", new_callable = AsyncMock), \ - patch.object(test_bot, "web_find_all", new_callable = AsyncMock), \ - patch.object(test_bot, "web_await", new_callable = AsyncMock), \ - patch("builtins.input", return_value = ""), \ - patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock): - + with ( + patch.object(test_bot, "web_execute", side_effect = mock_web_execute), + patch.object(test_bot, "web_click", new_callable = AsyncMock), + patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, + patch.object(test_bot, "web_select", new_callable = AsyncMock), + patch.object(test_bot, "web_input", new_callable = AsyncMock), + patch.object(test_bot, "web_open", new_callable = AsyncMock), + patch.object(test_bot, "web_sleep", new_callable = AsyncMock), + patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), + patch.object(test_bot, "web_request", new_callable = AsyncMock), + patch.object(test_bot, "web_find_all", new_callable = AsyncMock), + patch.object(test_bot, "web_await", new_callable = AsyncMock), + patch("builtins.input", return_value = ""), + patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock), + ): # Mock web_find to simulate element detection async def mock_find_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None: if selector_value == "meta[name=_csrf]": @@ -1076,21 +1094,17 @@ class TestKleinanzeigenBotShippingOptions: async def test_cross_drive_path_fallback_windows(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None: """Test that cross-drive path handling falls back to absolute path on Windows.""" # Create ad config - ad_cfg = Ad.model_validate(base_ad_config | { - "updated_on": "2024-01-01T00:00:00", - "created_on": "2024-01-01T00:00:00", - "auto_price_reduction": { - "enabled": True, - "strategy": "FIXED", - "amount": 10, - "min_price": 50, - "delay_reposts": 0, - "delay_days": 0 - }, - "price": 100, - "repost_count": 1, - "price_reduction_count": 0 - }) + ad_cfg = Ad.model_validate( + base_ad_config + | { + "updated_on": "2024-01-01T00:00:00", + "created_on": "2024-01-01T00:00:00", + "auto_price_reduction": {"enabled": True, "strategy": "FIXED", "amount": 10, "min_price": 50, "delay_reposts": 0, "delay_days": 0}, + "price": 100, + "repost_count": 1, + "price_reduction_count": 0, + } + ) ad_cfg.update_content_hash() ad_cfg_orig = ad_cfg.model_dump() @@ -1111,10 +1125,12 @@ class TestKleinanzeigenBotShippingOptions: raise _SentinelException("Abort early for test") # Mock Path to use PureWindowsPath for testing cross-drive behavior - with patch("kleinanzeigen_bot.Path", PureWindowsPath), \ - patch("kleinanzeigen_bot.apply_auto_price_reduction", side_effect = mock_apply_auto_price_reduction), \ - patch.object(test_bot, "web_open", new_callable = AsyncMock), \ - patch.object(test_bot, "delete_ad", new_callable = AsyncMock): + with ( + patch("kleinanzeigen_bot.Path", PureWindowsPath), + patch("kleinanzeigen_bot.apply_auto_price_reduction", side_effect = mock_apply_auto_price_reduction), + patch.object(test_bot, "web_open", new_callable = AsyncMock), + patch.object(test_bot, "delete_ad", new_callable = AsyncMock), + ): # Call publish_ad and expect sentinel exception try: await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE) @@ -1128,30 +1144,21 @@ class TestKleinanzeigenBotShippingOptions: assert recorded_path[0] == ad_file, f"Expected absolute path fallback, got: {recorded_path[0]}" @pytest.mark.asyncio - async def test_auto_price_reduction_only_on_replace_not_update( - self, - test_bot:KleinanzeigenBot, - base_ad_config:dict[str, Any], - tmp_path:Path - ) -> None: + async def test_auto_price_reduction_only_on_replace_not_update(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], tmp_path:Path) -> None: """Test that auto price reduction is ONLY applied on REPLACE mode, not UPDATE.""" # Create ad with auto price reduction enabled - ad_cfg = Ad.model_validate(base_ad_config | { - "id": 12345, - "price": 200, - "auto_price_reduction": { - "enabled": True, - "strategy": "FIXED", - "amount": 50, - "min_price": 50, - "delay_reposts": 0, - "delay_days": 0 - }, - "repost_count": 1, - "price_reduction_count": 0, - "updated_on": "2024-01-01T00:00:00", - "created_on": "2024-01-01T00:00:00" - }) + ad_cfg = Ad.model_validate( + base_ad_config + | { + "id": 12345, + "price": 200, + "auto_price_reduction": {"enabled": True, "strategy": "FIXED", "amount": 50, "min_price": 50, "delay_reposts": 0, "delay_days": 0}, + "repost_count": 1, + "price_reduction_count": 0, + "updated_on": "2024-01-01T00:00:00", + "created_on": "2024-01-01T00:00:00", + } + ) ad_cfg.update_content_hash() ad_cfg_orig = ad_cfg.model_dump() @@ -1159,22 +1166,23 @@ class TestKleinanzeigenBotShippingOptions: with patch("kleinanzeigen_bot.apply_auto_price_reduction") as mock_apply: # Mock other dependencies mock_response = {"statusCode": 200, "statusMessage": "OK", "content": "{}"} - with patch.object(test_bot, "web_find", new_callable = AsyncMock), \ - patch.object(test_bot, "web_input", new_callable = AsyncMock), \ - patch.object(test_bot, "web_click", new_callable = AsyncMock), \ - patch.object(test_bot, "web_open", new_callable = AsyncMock), \ - patch.object(test_bot, "web_select", new_callable = AsyncMock), \ - patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False), \ - patch.object(test_bot, "web_await", new_callable = AsyncMock), \ - patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \ - patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = mock_response), \ - patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = mock_response), \ - patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock), \ - patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []), \ - patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock), \ - patch("builtins.input", return_value = ""), \ - patch("kleinanzeigen_bot.utils.misc.ainput", new_callable = AsyncMock, return_value = ""): - + with ( + patch.object(test_bot, "web_find", new_callable = AsyncMock), + patch.object(test_bot, "web_input", new_callable = AsyncMock), + patch.object(test_bot, "web_click", new_callable = AsyncMock), + patch.object(test_bot, "web_open", new_callable = AsyncMock), + patch.object(test_bot, "web_select", new_callable = AsyncMock), + patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False), + patch.object(test_bot, "web_await", new_callable = AsyncMock), + patch.object(test_bot, "web_sleep", new_callable = AsyncMock), + patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = mock_response), + patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = mock_response), + patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock), + patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []), + patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock), + patch("builtins.input", return_value = ""), + patch("kleinanzeigen_bot.utils.misc.ainput", new_callable = AsyncMock, return_value = ""), + ): test_bot.page = MagicMock() test_bot.page.url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=12345" test_bot.config.publishing.delete_old_ads = "BEFORE_PUBLISH" @@ -1194,15 +1202,18 @@ class TestKleinanzeigenBotShippingOptions: async def test_special_attributes_with_non_string_values(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None: """Test that special attributes with non-string values are converted to strings.""" # Create ad config with string special attributes first (to pass validation) - ad_cfg = Ad.model_validate(base_ad_config | { - "special_attributes": { - "art_s": "12345", # String value initially - "condition_s": "67890", # String value initially - "color_s": "red" # String value - }, - "updated_on": "2024-01-01T00:00:00", - "created_on": "2024-01-01T00:00:00" - }) + ad_cfg = Ad.model_validate( + base_ad_config + | { + "special_attributes": { + "art_s": "12345", # String value initially + "condition_s": "67890", # String value initially + "color_s": "red", # String value + }, + "updated_on": "2024-01-01T00:00:00", + "created_on": "2024-01-01T00:00:00", + } + ) # Now modify the special attributes to non-string values to test str() conversion # This simulates the scenario where the values come from external sources as non-strings @@ -1234,11 +1245,12 @@ class TestKleinanzeigenBotShippingOptions: color_s_elem.local_name = "select" # Mock the necessary web interaction methods - with patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \ - patch.object(test_bot, "web_select", new_callable = AsyncMock) as mock_select, \ - patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), \ - patch.object(test_bot, "_KleinanzeigenBot__set_condition", new_callable = AsyncMock) as mock_set_condition: - + with ( + patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, + patch.object(test_bot, "web_select", new_callable = AsyncMock) as mock_select, + patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), + patch.object(test_bot, "_KleinanzeigenBot__set_condition", new_callable = AsyncMock) as mock_set_condition, + ): # Mock web_find to simulate element detection async def mock_find_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None: # Handle XPath queries for special attributes @@ -1284,23 +1296,22 @@ class TestKleinanzeigenBotUrlConstruction: class TestKleinanzeigenBotPrefixSuffix: """Tests for description prefix and suffix functionality.""" + # pylint: disable=protected-access - def test_description_prefix_suffix_handling( - self, - test_bot_config:Config, - description_test_cases:list[tuple[dict[str, Any], str, str]] - ) -> None: + def test_description_prefix_suffix_handling(self, test_bot_config:Config, description_test_cases:list[tuple[dict[str, Any], str, str]]) -> None: """Test handling of description prefix/suffix in various configurations.""" for config, raw_description, expected_description in description_test_cases: test_bot = KleinanzeigenBot() test_bot.config = test_bot_config.with_values(config) - ad_cfg = test_bot.load_ad({ - "description": raw_description, - "active": True, - "title": "0123456789", - "category": "whatever", - }) + ad_cfg = test_bot.load_ad( + { + "description": raw_description, + "active": True, + "title": "0123456789", + "category": "whatever", + } + ) # Access private method using the correct name mangling description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True) @@ -1309,18 +1320,15 @@ class TestKleinanzeigenBotPrefixSuffix: def test_description_length_validation(self, test_bot_config:Config) -> None: """Test that long descriptions with affixes raise appropriate error.""" test_bot = KleinanzeigenBot() - test_bot.config = test_bot_config.with_values({ - "ad_defaults": { - "description_prefix": "P" * 1000, - "description_suffix": "S" * 1000 + test_bot.config = test_bot_config.with_values({"ad_defaults": {"description_prefix": "P" * 1000, "description_suffix": "S" * 1000}}) + ad_cfg = test_bot.load_ad( + { + "description": "D" * 2001, # This plus affixes will exceed 4000 chars + "active": True, + "title": "0123456789", + "category": "whatever", } - }) - ad_cfg = test_bot.load_ad({ - "description": "D" * 2001, # This plus affixes will exceed 4000 chars - "active": True, - "title": "0123456789", - "category": "whatever", - }) + ) with pytest.raises(AssertionError) as exc_info: getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True) @@ -1338,12 +1346,14 @@ class TestKleinanzeigenBotDescriptionHandling: test_bot.config = test_bot_config # Test with a simple ad config - ad_cfg = test_bot.load_ad({ - "description": "Test Description", - "active": True, - "title": "0123456789", - "category": "whatever", - }) + ad_cfg = test_bot.load_ad( + { + "description": "Test Description", + "active": True, + "title": "0123456789", + "category": "whatever", + } + ) # The description should be returned as-is without any prefix/suffix description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True) @@ -1352,19 +1362,16 @@ class TestKleinanzeigenBotDescriptionHandling: def test_description_with_only_new_format_affixes(self, test_bot_config:Config) -> None: """Test that description works with only new format affixes in config.""" test_bot = KleinanzeigenBot() - test_bot.config = test_bot_config.with_values({ - "ad_defaults": { - "description_prefix": "Prefix: ", - "description_suffix": " :Suffix" - } - }) + test_bot.config = test_bot_config.with_values({"ad_defaults": {"description_prefix": "Prefix: ", "description_suffix": " :Suffix"}}) - ad_cfg = test_bot.load_ad({ - "description": "Test Description", - "active": True, - "title": "0123456789", - "category": "whatever", - }) + ad_cfg = test_bot.load_ad( + { + "description": "Test Description", + "active": True, + "title": "0123456789", + "category": "whatever", + } + ) description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True) assert description == "Prefix: Test Description :Suffix" @@ -1372,23 +1379,24 @@ class TestKleinanzeigenBotDescriptionHandling: def test_description_with_mixed_config_formats(self, test_bot_config:Config) -> None: """Test that description works with both old and new format affixes in config.""" test_bot = KleinanzeigenBot() - test_bot.config = test_bot_config.with_values({ - "ad_defaults": { - "description_prefix": "New Prefix: ", - "description_suffix": " :New Suffix", - "description": { - "prefix": "Old Prefix: ", - "suffix": " :Old Suffix" + test_bot.config = test_bot_config.with_values( + { + "ad_defaults": { + "description_prefix": "New Prefix: ", + "description_suffix": " :New Suffix", + "description": {"prefix": "Old Prefix: ", "suffix": " :Old Suffix"}, } } - }) + ) - ad_cfg = test_bot.load_ad({ - "description": "Test Description", - "active": True, - "title": "0123456789", - "category": "whatever", - }) + ad_cfg = test_bot.load_ad( + { + "description": "Test Description", + "active": True, + "title": "0123456789", + "category": "whatever", + } + ) description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True) assert description == "New Prefix: Test Description :New Suffix" @@ -1396,21 +1404,18 @@ class TestKleinanzeigenBotDescriptionHandling: def test_description_with_ad_level_affixes(self, test_bot_config:Config) -> None: """Test that ad-level affixes take precedence over config affixes.""" test_bot = KleinanzeigenBot() - test_bot.config = test_bot_config.with_values({ - "ad_defaults": { - "description_prefix": "Config Prefix: ", - "description_suffix": " :Config Suffix" - } - }) + test_bot.config = test_bot_config.with_values({"ad_defaults": {"description_prefix": "Config Prefix: ", "description_suffix": " :Config Suffix"}}) - ad_cfg = test_bot.load_ad({ - "description": "Test Description", - "description_prefix": "Ad Prefix: ", - "description_suffix": " :Ad Suffix", - "active": True, - "title": "0123456789", - "category": "whatever", - }) + ad_cfg = test_bot.load_ad( + { + "description": "Test Description", + "description_prefix": "Ad Prefix: ", + "description_suffix": " :Ad Suffix", + "active": True, + "title": "0123456789", + "category": "whatever", + } + ) description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True) assert description == "Ad Prefix: Test Description :Ad Suffix" @@ -1418,23 +1423,18 @@ class TestKleinanzeigenBotDescriptionHandling: def test_description_with_none_values(self, test_bot_config:Config) -> None: """Test that None values in affixes are handled correctly.""" test_bot = KleinanzeigenBot() - test_bot.config = test_bot_config.with_values({ - "ad_defaults": { - "description_prefix": None, - "description_suffix": None, - "description": { - "prefix": None, - "suffix": None - } - } - }) + test_bot.config = test_bot_config.with_values( + {"ad_defaults": {"description_prefix": None, "description_suffix": None, "description": {"prefix": None, "suffix": None}}} + ) - ad_cfg = test_bot.load_ad({ - "description": "Test Description", - "active": True, - "title": "0123456789", - "category": "whatever", - }) + ad_cfg = test_bot.load_ad( + { + "description": "Test Description", + "active": True, + "title": "0123456789", + "category": "whatever", + } + ) description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True) assert description == "Test Description" @@ -1444,12 +1444,14 @@ class TestKleinanzeigenBotDescriptionHandling: test_bot = KleinanzeigenBot() test_bot.config = test_bot_config - ad_cfg = test_bot.load_ad({ - "description": "Contact: test@example.com", - "active": True, - "title": "0123456789", - "category": "whatever", - }) + ad_cfg = test_bot.load_ad( + { + "description": "Contact: test@example.com", + "active": True, + "title": "0123456789", + "category": "whatever", + } + ) description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True) assert description == "Contact: test(at)example.com" @@ -1463,23 +1465,12 @@ class TestKleinanzeigenBotChangedAds: # Set up the bot with the 'changed' selector test_bot = KleinanzeigenBot() test_bot.ads_selector = "changed" - test_bot.config = test_bot_config.with_values({ - "ad_defaults": { - "description": { - "prefix": "", - "suffix": "" - } - } - }) + test_bot.config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "", "suffix": ""}}}) # Create a changed ad - ad_cfg = Ad.model_validate(base_ad_config | { - "id": "12345", - "title": "Changed Ad", - "updated_on": "2024-01-01T00:00:00", - "created_on": "2024-01-01T00:00:00", - "active": True - }) + ad_cfg = Ad.model_validate( + base_ad_config | {"id": "12345", "title": "Changed Ad", "updated_on": "2024-01-01T00:00:00", "created_on": "2024-01-01T00:00:00", "active": True} + ) # Calculate hash for changed_ad and add it to the config # Then modify the ad to simulate a change @@ -1503,10 +1494,13 @@ class TestKleinanzeigenBotChangedAds: test_bot.config.ad_files = ["ads/*.yaml"] # Mock the loading of the ad configuration - with patch("kleinanzeigen_bot.utils.dicts.load_dict", side_effect = [ - changed_ad, # First call returns the changed ad - {} # Second call for ad_fields.yaml - ]): + with patch( + "kleinanzeigen_bot.utils.dicts.load_dict", + side_effect = [ + changed_ad, # First call returns the changed ad + {}, # Second call for ad_fields.yaml + ], + ): ads_to_publish = test_bot.load_ads() # The changed ad should be loaded @@ -1522,14 +1516,17 @@ class TestKleinanzeigenBotChangedAds: current_time = misc.now() old_date = (current_time - timedelta(days = 10)).isoformat() # Past republication interval - ad_cfg = Ad.model_validate(base_ad_config | { - "id": "12345", - "title": "Changed Ad", - "updated_on": old_date, - "created_on": old_date, - "republication_interval": 7, # Due for republication after 7 days - "active": True - }) + ad_cfg = Ad.model_validate( + base_ad_config + | { + "id": "12345", + "title": "Changed Ad", + "updated_on": old_date, + "created_on": old_date, + "republication_interval": 7, # Due for republication after 7 days + "active": True, + } + ) changed_ad = ad_cfg.model_dump() # Create temporary directory and file @@ -1546,10 +1543,13 @@ class TestKleinanzeigenBotChangedAds: test_bot.config.ad_files = ["ads/*.yaml"] # Mock the loading of the ad configuration - with patch("kleinanzeigen_bot.utils.dicts.load_dict", side_effect = [ - changed_ad, # First call returns the changed ad - {} # Second call for ad_fields.yaml - ]): + with patch( + "kleinanzeigen_bot.utils.dicts.load_dict", + side_effect = [ + changed_ad, # First call returns the changed ad + {}, # Second call for ad_fields.yaml + ], + ): ads_to_publish = test_bot.load_ads() # The changed ad should be loaded with 'due' selector because it's due for republication