From c425193b101d42a1bb25a734ee635b5fde58d3c9 Mon Sep 17 00:00:00 2001 From: Jens Bergmann <1742418+1cu@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:09:40 +0200 Subject: [PATCH] feat: add create-config subcommand to generate default config (#577) --- README.md | 8 +- src/kleinanzeigen_bot/__init__.py | 80 +++++++++++-------- .../resources/translations.de.yaml | 3 +- tests/smoke/test_smoke_health.py | 22 ++++- tests/unit/test_bot.py | 33 +++++++- tests/unit/test_init.py | 11 +-- 6 files changed, 111 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index a634a56..34dcf7f 100644 --- a/README.md +++ b/README.md @@ -187,9 +187,13 @@ Usage: kleinanzeigen-bot COMMAND [OPTIONS] Commands: publish - (re-)publishes ads verify - verifies the configuration files - update - updates published ads delete - deletes ads + update - updates published ads download - downloads one or multiple ads + update-check - checks for available updates + update-content-hash – recalculates each ad's content_hash based on the current ad_defaults; + use this after changing config.yaml/ad_defaults to avoid every ad being marked "changed" and republished + create-config - creates a new default configuration file if one does not exist -- help - displays this help (default command) version - displays the application version @@ -220,6 +224,8 @@ Options: -v, --verbose - enables verbose output - only useful when troubleshooting issues ``` +> **Note:** The output of `kleinanzeigen-bot help` is always the most up-to-date reference for available commands and options. + Limitation of `download`: It's only possible to extract the cheapest given shipping option. ## Configuration diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index e6f7ce9..808896e 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -79,6 +79,9 @@ class KleinanzeigenBot(WebScrapingMixin): return case "version": print(self.get_version()) + case "create-config": + self.create_default_config() + return case "verify": self.configure_file_logging() self.load_config() @@ -203,6 +206,7 @@ class KleinanzeigenBot(WebScrapingMixin): update-content-hash - Berechnet den content_hash aller Anzeigen anhand der aktuellen ad_defaults neu; nach Änderungen an den config.yaml/ad_defaults verhindert es, dass alle Anzeigen als "geändert" gelten und neu veröffentlicht werden. + create-config - Erstellt eine neue Standard-Konfigurationsdatei, falls noch nicht vorhanden -- help - Zeigt diese Hilfe an (Standardbefehl) version - Zeigt die Version der Anwendung an @@ -246,6 +250,7 @@ class KleinanzeigenBot(WebScrapingMixin): update-check - checks for available updates update-content-hash – recalculates each ad's content_hash based on the current ad_defaults; use this after changing config.yaml/ad_defaults to avoid every ad being marked "changed" and republished + create-config - creates a new default configuration file if one does not exist -- help - displays this help (default command) version - displays the application version @@ -338,6 +343,48 @@ class KleinanzeigenBot(WebScrapingMixin): LOG.info("App version: %s", self.get_version()) LOG.info("Python version: %s", sys.version) + def create_default_config(self) -> None: + """ + Create a default config.yaml in the project root if it does not exist. + If it exists, log an error and inform the user. + """ + if os.path.exists(self.config_file_path): + LOG.error("Config file %s already exists. Aborting creation.", self.config_file_path) + return + default_config = Config.model_construct() + default_config.login.username = "changeme" # noqa: S105 placeholder for default config, not a real username + default_config.login.password = "changeme" # noqa: S105 placeholder for default config, not a real password + dicts.save_dict( + self.config_file_path, + 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: + # write default config.yaml if config file does not exist + if not os.path.exists(self.config_file_path): + 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) + + # load built-in category mappings + self.categories = dicts.load_dict_from_module(resources, "categories.yaml", "categories") + deprecated_categories = dicts.load_dict_from_module(resources, "categories_old.yaml", "categories") + self.categories.update(deprecated_categories) + if self.config.categories: + self.categories.update(self.config.categories) + LOG.info(" -> found %s", pluralize("category", self.categories)) + + # 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.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.profile_name = self.config.browser.profile_name + 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. @@ -517,39 +564,6 @@ class KleinanzeigenBot(WebScrapingMixin): def load_ad(self, ad_cfg_orig:dict[str, Any]) -> Ad: return AdPartial.model_validate(ad_cfg_orig).to_ad(self.config.ad_defaults) - def load_config(self) -> None: - # write default config.yaml if config file does not exist - if not os.path.exists(self.config_file_path): - LOG.warning("Config file %s does not exist. Creating it with default values...", self.config_file_path) - default_config = Config.model_construct() - default_config.login.username = "changeme" # noqa: S105 placeholder for default config, not a real username - default_config.login.password = "changeme" # noqa: S105 placeholder for default config, not a real password - dicts.save_dict(self.config_file_path, default_config.model_dump(exclude_none = True, exclude = { - "ad_defaults": { - "description" # deprecated - } - }), header = "# yaml-language-server: $schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/config.schema.json") - - 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) - - # load built-in category mappings - self.categories = dicts.load_dict_from_module(resources, "categories.yaml", "categories") - deprecated_categories = dicts.load_dict_from_module(resources, "categories_old.yaml", "categories") - self.categories.update(deprecated_categories) - if self.config.categories: - self.categories.update(self.config.categories) - LOG.info(" -> found %s", pluralize("category", self.categories)) - - # 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.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.profile_name = self.config.browser.profile_name - async def check_and_wait_for_captcha(self, *, is_login_page:bool = True) -> None: try: await self.web_find(By.CSS_SELECTOR, diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index 2625bc2..bc70186 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -23,6 +23,8 @@ kleinanzeigen_bot/__init__.py: ################################################# module: "Direct execution not supported. Use 'pdm run app'": "Direkte Ausführung nicht unterstützt. Bitte 'pdm run app' verwenden" + create_default_config: + "Config file %s already exists. Aborting creation.": "Konfigurationsdatei %s existiert bereits. Erstellung abgebrochen." configure_file_logging: "Logging to [%s]...": "Protokollierung in [%s]..." @@ -48,7 +50,6 @@ kleinanzeigen_bot/__init__.py: "ad": "Anzeige" load_config: - "Config file %s does not exist. Creating it with default values...": "Konfigurationsdatei %s existiert nicht. Erstelle sie mit Standardwerten..." "config": "Konfiguration" " -> found %s": "-> %s gefunden" "category": "Kategorie" diff --git a/tests/smoke/test_smoke_health.py b/tests/smoke/test_smoke_health.py index a8eae6a..578526e 100644 --- a/tests/smoke/test_smoke_health.py +++ b/tests/smoke/test_smoke_health.py @@ -18,6 +18,16 @@ from kleinanzeigen_bot.utils import i18n from tests.conftest import DummyBrowser, DummyPage, SmokeKleinanzeigenBot +def run_cli_subcommand(args:list[str], cwd:str | None = None) -> subprocess.CompletedProcess[str]: + """ + Run the kleinanzeigen-bot CLI as a subprocess with the given arguments. + Returns the CompletedProcess object. + """ + cli_module = "kleinanzeigen_bot.__main__" + cmd = [sys.executable, "-m", cli_module] + args + return subprocess.run(cmd, check = False, capture_output = True, text = True, cwd = cwd) # noqa: S603 + + @pytest.mark.smoke def test_app_starts(smoke_bot:SmokeKleinanzeigenBot) -> None: """Smoke: Bot can be instantiated and started without error.""" @@ -97,7 +107,15 @@ def test_dummy_browser_session() -> None: @pytest.mark.smoke def test_cli_entrypoint_help_runs() -> None: """Smoke: CLI entry point runs with --help and exits cleanly (subprocess).""" - cli_module = "kleinanzeigen_bot.__main__" - result = subprocess.run([sys.executable, "-m", cli_module, "--help"], check = False, capture_output = True, text = True) # noqa: S603 + result = run_cli_subcommand(["--help"]) assert result.returncode in {0, 1}, f"CLI exited with unexpected code: {result.returncode}\nstdout: {result.stdout}\nstderr: {result.stderr}" assert "Usage" in result.stdout or "usage" in result.stdout or "help" in result.stdout.lower(), f"No help text in CLI output: {result.stdout}" + + +@pytest.mark.smoke +def test_cli_create_config_creates_file(tmp_path:Path) -> None: + """Smoke: CLI 'create-config' creates a config.yaml file in the current directory.""" + result = run_cli_subcommand(["create-config"], cwd = str(tmp_path)) + config_path = tmp_path / "config.yaml" + assert result.returncode == 0, f"CLI exited with code {result.returncode}\nstdout: {result.stdout}\nstderr: {result.stderr}" + assert config_path.exists(), "config.yaml was not created by create-config command" diff --git a/tests/unit/test_bot.py b/tests/unit/test_bot.py index 4448f1f..35cbf45 100644 --- a/tests/unit/test_bot.py +++ b/tests/unit/test_bot.py @@ -2,12 +2,12 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ import gc, pytest # isort: skip +import pathlib from kleinanzeigen_bot import KleinanzeigenBot class TestKleinanzeigenBot: - @pytest.fixture def bot(self) -> KleinanzeigenBot: return KleinanzeigenBot() @@ -26,6 +26,37 @@ class TestKleinanzeigenBot: assert bot.ads_selector == "all" assert bot.keep_old_ads + def test_parse_args_create_config(self, bot:KleinanzeigenBot) -> None: + """Test parsing of create-config command""" + bot.parse_args(["app", "create-config"]) + assert bot.command == "create-config" + + def test_create_default_config_logs_error_if_exists(self, tmp_path:pathlib.Path, bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None: + """Test that create_default_config logs an error if the config file already exists.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("dummy: value") + bot.config_file_path = str(config_path) + with caplog.at_level("ERROR"): + bot.create_default_config() + assert any("already exists" in m for m in caplog.messages) + + def test_create_default_config_creates_file(self, tmp_path:pathlib.Path, bot:KleinanzeigenBot) -> None: + """Test that create_default_config creates a config file if it does not exist.""" + config_path = tmp_path / "config.yaml" + bot.config_file_path = str(config_path) + assert not config_path.exists() + bot.create_default_config() + assert config_path.exists() + content = config_path.read_text() + assert "username: changeme" in content + + def test_load_config_handles_missing_file(self, tmp_path:pathlib.Path, bot:KleinanzeigenBot) -> None: + """Test that load_config creates a default config file if missing. No info log is expected anymore.""" + config_path = tmp_path / "config.yaml" + bot.config_file_path = str(config_path) + bot.load_config() + assert config_path.exists() + def test_get_version(self, bot:KleinanzeigenBot) -> None: """Test version retrieval""" version = bot.get_version() diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 2baa1f1..54c3267 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -291,17 +291,12 @@ class TestKleinanzeigenBotConfiguration: test_bot:KleinanzeigenBot, test_data_dir:str ) -> None: - """Verify that loading a missing config file creates default config.""" + """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) test_bot.config_file_path = str(config_path) - - with patch.object(LOG, "warning") as mock_warning: - test_bot.load_config() - mock_warning.assert_called_once() - assert config_path.exists() - assert test_bot.config.login.username == "changeme" # noqa: S105 placeholder for default config, not a real username - assert test_bot.config.login.password == "changeme" # noqa: S105 placeholder for default config, not a real password + test_bot.load_config() + assert config_path.exists() def test_load_config_validates_required_fields(self, test_bot:KleinanzeigenBot, test_data_dir:str) -> None: """Verify that config validation checks required fields."""