mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 02:31:45 +01:00
feat: add create-config subcommand to generate default config (#577)
This commit is contained in:
@@ -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.
|
||||
|
||||
## <a name="config"></a>Configuration
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user