From eda06334d129513a0538b07d9db5338c846efbd7 Mon Sep 17 00:00:00 2001 From: Jens <1742418+1cu@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:36:03 +0200 Subject: [PATCH] docs: generate README usage section (#1191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ℹ️ Description - Link to the related issue(s): N/A - Keeps the README Usage command block in sync with the CLI help by generating it as part of the artifact pipeline. ## 📋 Changes Summary - Added a README Usage generator that renders normalized English CLI help between explicit README markers. - Added `generate-readme-commands` and wired it into `generate-artifacts`. - Extended generated artifact checks to detect README Usage drift without mutating files. - Added focused unit tests for marker replacement, locale/executable normalization, ANSI stripping, and command coverage. - No dependency changes introduced. ### ⚙️ Type of Change Select the type(s) of change(s) included in this pull request: - [ ] 🐞 Bug fix (non-breaking change which fixes an issue) - [x] ✨ New feature (adds new functionality without breaking existing usage) - [ ] 💥 Breaking change (changes that might break existing user setups, scripts, or configurations) ## ✅ Checklist Before requesting a review, confirm the following: - [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** * Kept the README “Usage” section automatically synchronized with the app’s CLI help. * Added a new `--preserve-local-settings` option to the CLI help output. * **Bug Fixes** * Improved and clarified CLI command/option descriptions (notably around `verify`, `extend`, and `update` behavior). * CI now fails early if the generated README “Usage” content is out of date. * **Documentation** * Regenerated the README “Usage” console block to reflect the latest help text and command details. * Updated agent guidance to match the expanded README generation behavior. --- AGENTS.md | 2 +- README.md | 21 +-- pyproject.toml | 3 +- scripts/check_generated_artifacts.py | 55 ++++++- scripts/generate_readme_commands.py | 136 ++++++++++++++++++ src/kleinanzeigen_bot/cli.py | 19 +-- tests/unit/test_generate_readme_commands.py | 152 ++++++++++++++++++++ 7 files changed, 363 insertions(+), 25 deletions(-) create mode 100644 scripts/generate_readme_commands.py create mode 100644 tests/unit/test_generate_readme_commands.py diff --git a/AGENTS.md b/AGENTS.md index 118a911..d0a9ccc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ When changing models, config defaults, schema-affecting validators, or `create-c - `pdm run generate-schemas` — regenerates `schemas/*.json` - `pdm run generate-config` — regenerates `docs/config.default.yaml` -- `pdm run generate-artifacts` — runs both +- `pdm run generate-artifacts` — runs schemas, config, and README usage regeneration CI and workflows are the source of truth for the exact required checks, coverage gates, generated-artifact verification, and PR title validation. diff --git a/README.md b/README.md index fa83ed1..b934064 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,7 @@ Die Nutzung erfolgt auf eigenes Risiko. Jede rechtswidrige Verwendung ist unters ## Usage + ```console Usage: kleinanzeigen-bot COMMAND [OPTIONS] @@ -208,13 +209,14 @@ Commands: verify - verifies the configuration files and previews automatic price reduction outcomes delete - deletes ads update - updates published ads + extend - extends ads within the 8-day window before expiry (keeps watchers/savers and does not count towards the monthly ad quota) download - downloads one or multiple ads - extend - extends active ads that expire soon (keeps watchers/savers and does not count towards the monthly ad quota) 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 + 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 diagnose - diagnoses browser connection issues and shows troubleshooting information + status - shows status overview of ads -- help - displays this help (default command) version - displays the application version @@ -233,23 +235,26 @@ Options: * all: downloads all ads from your profile * new: downloads ads from your profile that are not locally saved yet * : provide one or several ads by ID to download, like e.g. "--ads=1,2,3" - --ads=all| (extend) - specifies which ads to extend (DEFAULT: all) - Possible values: - * all: extend all eligible ads in your profile - * : provide one or several ads by ID to extend, like e.g. "--ads=1,2,3" - * Note: kleinanzeigen.de only allows extending ads within 8 days of expiry; ads outside this window are skipped. - --ads=changed| (update) - specifies which ads to update (DEFAULT: changed) + --ads=all|changed| (update) - specifies which ads to update (DEFAULT: changed) Possible values: + * all: update all ads * changed: only update ads that have been modified since last publication * : provide one or several ads by ID to update, like e.g. "--ads=1,2,3" + --ads=all| (extend) - specifies which ads to extend (DEFAULT: all) + Possible values: + * all: extend all ads expiring within 8 days + * : specify ad IDs to extend, e.g. "--ads=1,2,3" + * Note: ads outside the 8-day window are skipped. --force - alias for '--ads=all' --keep-old - don't delete old ads on republication + --preserve-local-settings - force-enable preservation of local-only settings on re-download (overrides config value of false) --config= - path to the config YAML or JSON file (does not implicitly change workspace mode) --workspace-mode=portable|xdg - overrides workspace mode for this run --logfile= - path to the logfile (DEFAULT: depends on active workspace mode) --lang=en|de - display language (STANDARD: system language if supported, otherwise English) -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. diff --git a/pyproject.toml b/pyproject.toml index 2283d5b..db5bb78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,8 @@ debug = "python -m pdb -m kleinanzeigen_bot" # build & packaging generate-schemas = "python scripts/generate_schemas.py" generate-config = { shell = "python -c \"from pathlib import Path; Path('docs/config.default.yaml').unlink(missing_ok=True)\" && python -m kleinanzeigen_bot --config docs/config.default.yaml create-config" } -generate-artifacts = { composite = ["generate-schemas", "generate-config"] } +generate-readme-commands = "python scripts/generate_readme_commands.py" +generate-artifacts = { composite = ["generate-schemas", "generate-config", "generate-readme-commands"] } compile.cmd = "python -O -m PyInstaller pyinstaller.spec --clean --workpath .temp" compile.env = {PYTHONHASHSEED = "1", SOURCE_DATE_EPOCH = "0"} # https://pyinstaller.org/en/stable/advanced-topics.html#creating-a-reproducible-build diff --git a/scripts/check_generated_artifacts.py b/scripts/check_generated_artifacts.py index 1e123c5..0664ac4 100644 --- a/scripts/check_generated_artifacts.py +++ b/scripts/check_generated_artifacts.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: © Jens Bergmann and contributors # SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ -"""CI guard: verifies generated schema and default-config artifacts are up-to-date.""" +"""CI guard: verifies generated schema, default-config, and README artifacts are up-to-date.""" from __future__ import annotations @@ -12,6 +12,7 @@ import tempfile from pathlib import Path from typing import TYPE_CHECKING, Final +from generate_readme_commands import build_usage_block, replace_marked_section from schema_utils import generate_schema_content from kleinanzeigen_bot.model.ad_model import AdPartial @@ -111,13 +112,48 @@ def get_default_config_diff(repo_root:Path) -> str: ) +def get_readme_diff(repo_root:Path) -> str: + """ + Compare committed README.md with in-memory generated content and return a unified diff string. + + Never mutates README.md — computes expected content entirely in memory. + """ + readme_path = repo_root / "README.md" + if not readme_path.is_file(): + raise FileNotFoundError(f"Missing README.md: {readme_path}") + + committed = readme_path.read_text(encoding = "utf-8") + new_block = build_usage_block() + + try: + expected = replace_marked_section(committed, new_block) + except ValueError as exc: + raise RuntimeError(f"README marker validation failed: {exc}") from exc + + if committed == expected: + return "" + + return "".join( + difflib.unified_diff( + committed.splitlines(keepends = True), + expected.splitlines(keepends = True), + fromfile = "README.md", + tofile = "", + ) + ) + + def main() -> None: repo_root = Path(__file__).resolve().parent.parent - schema_diffs = get_schema_diffs(repo_root) - default_config_diff = get_default_config_diff(repo_root) + try: + schema_diffs = get_schema_diffs(repo_root) + default_config_diff = get_default_config_diff(repo_root) + readme_diff = get_readme_diff(repo_root) + except (FileNotFoundError, RuntimeError) as exc: + raise SystemExit(f"Error while checking generated artifacts: {exc}") from exc - if schema_diffs or default_config_diff: + if schema_diffs or default_config_diff or readme_diff: messages:list[str] = ["Generated artifacts are not up-to-date."] if schema_diffs: @@ -130,13 +166,18 @@ def main() -> None: messages.append("Outdated docs/config.default.yaml detected.") messages.append(default_config_diff) + if readme_diff: + messages.append("Outdated README.md detected (Usage section).") + messages.append(readme_diff) + messages.append("Regenerate with one of the following:") - messages.append("- Schema files: pdm run generate-schemas") + messages.append("- Schema files: pdm run generate-schemas") messages.append("- Default config snapshot: pdm run generate-config") - messages.append("- Both: pdm run generate-artifacts") + messages.append("- README Usage section: pdm run generate-readme-commands") + messages.append("- All: pdm run generate-artifacts") raise SystemExit("\n".join(messages)) - print("Generated schemas and docs/config.default.yaml are up-to-date.") + print("Generated schemas, docs/config.default.yaml, and README.md are up-to-date.") if __name__ == "__main__": diff --git a/scripts/generate_readme_commands.py b/scripts/generate_readme_commands.py new file mode 100644 index 0000000..1ef095c --- /dev/null +++ b/scripts/generate_readme_commands.py @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: © Jens Bergmann and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ +"""Generate the CLI Usage console block in README.md from source of truth. + +Replaces the content between ```` and +```` markers in README.md with the +normalized English help text from :func:`kleinanzeigen_bot.cli.help_text`. + +Pure helpers (:func:`build_usage_block`, :func:`replace_marked_section`) are +importable for in-memory use by the artifact checker. +""" +from __future__ import annotations + +import pathlib +import re +import sys + +from kleinanzeigen_bot.cli import help_text + +START_MARKER:str = "" +END_MARKER:str = "" +README_PATH:pathlib.Path = pathlib.Path(__file__).resolve().parent.parent / "README.md" +_ANSI_RE = re.compile(r"\x1B(?:\[[0-9;]*[A-Za-z]|\[)") + + +# --------------------------------------------------------------------------- +# Pure helpers +# --------------------------------------------------------------------------- + + +def build_usage_block() -> str: + """Build the normalized Usage console block for the README. + + Returns a markdown console-fenced block (including leading/trailing + newlines) ready to be placed between the start/end markers. + + Forces: + * executable name → ``kleinanzeigen-bot`` + * language → English (``en``) + * no ANSI escape codes + * no subprocess (imports ``help_text`` directly) + """ + raw = help_text(executable = "kleinanzeigen-bot", language = "en") + clean = _strip_ansi(raw) + return f"\n```console\n{clean}\n```\n" + + +def replace_marked_section(text:str, new_block:str) -> str: + """Replace content between README markers with *new_block*. + + Validates that both markers exist exactly once and are in the correct + order. Raises :class:`ValueError` with a clear message on failure. + + The markers themselves are preserved; only the content between them + (including surrounding whitespace) is replaced. + """ + _validate_markers(text) + + start_idx = text.find(START_MARKER) + len(START_MARKER) + end_idx = text.find(END_MARKER) + + before = text[:start_idx] + after = text[end_idx:] + return before + new_block + after + + +def _validate_markers(text:str) -> None: + """Check marker presence, uniqueness, and order.""" + start_count = text.count(START_MARKER) + end_count = text.count(END_MARKER) + + if start_count == 0: + raise ValueError( + f"Missing start marker {START_MARKER!r} in README. " + f"Add it before the generated Usage console block." + ) + if end_count == 0: + raise ValueError( + f"Missing end marker {END_MARKER!r} in README. " + f"Add it after the generated Usage console block." + ) + if start_count > 1: + raise ValueError( + f"Duplicate start marker {START_MARKER!r}: found {start_count} occurrences. " + f"Exactly one is required." + ) + if end_count > 1: + raise ValueError( + f"Duplicate end marker {END_MARKER!r}: found {end_count} occurrences. " + f"Exactly one is required." + ) + + start_pos = text.find(START_MARKER) + end_pos = text.find(END_MARKER) + if start_pos > end_pos: + raise ValueError( + f"Markers are reversed: start marker found at position {start_pos} " + f"after end marker at position {end_pos}." + ) + + +def _strip_ansi(text:str) -> str: + """Remove ANSI escape sequences from *text*.""" + return _ANSI_RE.sub("", text) + + +# --------------------------------------------------------------------------- +# Entrypoint +# --------------------------------------------------------------------------- + + +def update_readme() -> None: + """Read README.md, replace the marked section, and write back. + + Exits with status 1 on validation errors. + """ + original = README_PATH.read_text(encoding = "utf-8") + new_block = build_usage_block() + + try: + updated = replace_marked_section(original, new_block) + except ValueError as exc: + print(f"Error: {exc}", file = sys.stderr) + sys.exit(1) + + README_PATH.write_text(updated, encoding = "utf-8") + print(f"[OK] Updated {README_PATH}") + + +def main() -> None: + update_readme() + + +if __name__ == "__main__": + main() diff --git a/src/kleinanzeigen_bot/cli.py b/src/kleinanzeigen_bot/cli.py index 29659c8..8856a2d 100644 --- a/src/kleinanzeigen_bot/cli.py +++ b/src/kleinanzeigen_bot/cli.py @@ -117,19 +117,20 @@ def _help_executable() -> str: return "python -m kleinanzeigen_bot" -def _help_text() -> str: - exe = _help_executable() - if get_current_locale().language == "de": +def help_text(*, executable:str | None = None, language:str | None = None) -> str: + exe = executable if executable is not None else _help_executable() + lang = language if language is not None else get_current_locale().language + if lang == "de": return textwrap.dedent( f"""\ Verwendung: {colorama.Fore.LIGHTMAGENTA_EX}{exe} BEFEHL [OPTIONEN]{colorama.Style.RESET_ALL} Befehle: publish - (Wieder-)Veröffentlicht Anzeigen - verify - Überprüft die Konfigurationsdateien + verify - Überprüft die Konfigurationsdateien und zeigt Preissenkungsvorschauen an delete - Löscht Anzeigen update - Aktualisiert bestehende Anzeigen - extend - Verlängert Anzeigen innerhalb des 8-Tage-Zeitfensters + extend - Verlängert Anzeigen im 8-Tage-Zeitfenster (behält Beobachter/Interessenten bei und zählt nicht zum monatlichen Anzeigenkontingent) download - Lädt eine oder mehrere Anzeigen herunter update-check - Prüft auf verfügbare Updates update-content-hash - Berechnet den content_hash aller Anzeigen anhand der aktuellen ad_defaults neu; @@ -166,6 +167,7 @@ def _help_text() -> str: Mögliche Werte: * all: Verlängert alle Anzeigen, die innerhalb von 8 Tagen ablaufen * : Gibt bestimmte Anzeigen-IDs an, z. B. "--ads=1,2,3" + * Hinweis: Anzeigen außerhalb des 8-Tage-Fensters werden übersprungen. --force - Alias für '--ads=all' --keep-old - Verhindert das Löschen alter Anzeigen bei erneuter Veröffentlichung --preserve-local-settings - Erzwingt das Beibehalten lokaler Einstellungen bei erneutem Download (überschreibt config-Wert false) @@ -183,10 +185,10 @@ def _help_text() -> str: Commands: publish - (re-)publishes ads - verify - verifies the configuration files + verify - verifies the configuration files and previews automatic price reduction outcomes delete - deletes ads update - updates published ads - extend - extends ads within the 8-day window before expiry + extend - extends ads within the 8-day window before expiry (keeps watchers/savers and does not count towards the monthly ad quota) 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; @@ -221,6 +223,7 @@ def _help_text() -> str: Possible values: * all: extend all ads expiring within 8 days * : specify ad IDs to extend, e.g. "--ads=1,2,3" + * Note: ads outside the 8-day window are skipped. --force - alias for '--ads=all' --keep-old - don't delete old ads on republication --preserve-local-settings - force-enable preservation of local-only settings on re-download (overrides config value of false) @@ -234,7 +237,7 @@ def _help_text() -> str: def show_help() -> None: - print(_help_text()) + print(help_text()) def parse_args(args:Sequence[str]) -> ParsedArgs: diff --git a/tests/unit/test_generate_readme_commands.py b/tests/unit/test_generate_readme_commands.py new file mode 100644 index 0000000..e1c50a4 --- /dev/null +++ b/tests/unit/test_generate_readme_commands.py @@ -0,0 +1,152 @@ +# SPDX-FileCopyrightText: © Jens Bergmann and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ +"""Unit tests for scripts/generate_readme_commands.py helpers.""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from kleinanzeigen_bot import runtime_config +from kleinanzeigen_bot.utils.i18n import Locale, get_current_locale, set_current_locale + +# Ensure scripts/ is importable as a top-level module, matching the import style +# used by scripts/check_generated_artifacts.py and avoiding the mypy +# double-module mapping ("generate_readme_commands" vs "scripts.generate_readme_commands"). +_SCRIPTS_DIR = str(Path(__file__).resolve().parent.parent.parent / "scripts") +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +from generate_readme_commands import ( # noqa: E402 # pyright: ignore[reportMissingImports] + END_MARKER, + START_MARKER, + build_usage_block, + replace_marked_section, +) + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Marker replacement — success cases +# --------------------------------------------------------------------------- + + +class TestReplaceMarkers: + def test_replaces_content_between_markers(self) -> None: + text = f"before\n{START_MARKER}\nold\n{END_MARKER}\nafter" + result = replace_marked_section(text, "\nNEW\n") + assert result == f"before\n{START_MARKER}\nNEW\n{END_MARKER}\nafter" + + def test_preserves_surrounding_context(self) -> None: + text = f"HEADER\n{START_MARKER}\nSTALE\n{END_MARKER}\nFOOTER" + result = replace_marked_section(text, "\nFRESH\n") + assert "HEADER" in result + assert "FOOTER" in result + assert "STALE" not in result + + def test_noop_when_content_identical(self) -> None: + block = "\n```console\nUsage: kleinanzeigen-bot\n```\n" + text = f"preamble\n{START_MARKER}{block}{END_MARKER}\npostamble" + result = replace_marked_section(text, block) + assert result == text + + +# --------------------------------------------------------------------------- +# Marker validation — failure cases +# --------------------------------------------------------------------------- + + +class TestReplaceMarkersErrors: + @pytest.mark.parametrize( + ("text", "expected_substring"), + [ + pytest.param( + "no markers here", + "Missing start marker", + id = "missing-start", + ), + pytest.param( + f"only {END_MARKER}", + "Missing start marker", + id = "missing-start-but-has-end", + ), + pytest.param( + f"{START_MARKER}\nno end", + "Missing end marker", + id = "missing-end", + ), + pytest.param( + f"{END_MARKER}\nno start", + "Missing start marker", + id = "missing-both-start-actually-end-first", + ), + pytest.param( + f"{START_MARKER}\n{START_MARKER}\n{END_MARKER}", + "Duplicate start marker", + id = "duplicate-start", + ), + pytest.param( + f"{START_MARKER}\n{END_MARKER}\n{END_MARKER}", + "Duplicate end marker", + id = "duplicate-end", + ), + pytest.param( + f"{END_MARKER}\n{START_MARKER}", + "reversed", + id = "reversed-markers", + ), + ], + ) + def test_raises_value_error(self, text:str, expected_substring:str) -> None: + with pytest.raises(ValueError, match = expected_substring): + replace_marked_section(text, "\nBLOCK\n") + + +# --------------------------------------------------------------------------- +# Generated help block — normalization +# --------------------------------------------------------------------------- + + +class TestBuildUsageBlock: + def test_contains_expected_executable_name(self) -> None: + block = build_usage_block() + assert "Usage: kleinanzeigen-bot COMMAND [OPTIONS]" in block + + def test_contains_no_ansi_escape_codes(self) -> None: + block = build_usage_block() + assert "\x1b[" not in block + + def test_wrapped_in_console_fence(self) -> None: + block = build_usage_block() + assert block.startswith("\n```console\n") + assert block.endswith("\n```\n") + + def test_is_english_regardless_of_current_locale(self) -> None: + """Force German locale, then verify generated help is still English.""" + saved = get_current_locale() + try: + set_current_locale(Locale("de")) + block = build_usage_block() + assert "Commands:" in block + assert "Befehle:" not in block + finally: + set_current_locale(saved) + + +# --------------------------------------------------------------------------- +# Generated help — command coverage +# --------------------------------------------------------------------------- + + +class TestBuildUsageBlockCommands: + """Verify that generated help covers the known command set.""" + + def test_every_valid_command_appears_in_generated_help(self) -> None: + block = build_usage_block() + for cmd in runtime_config.VALID_COMMANDS: + assert cmd in block, ( + f"Command {cmd!r} is in VALID_COMMANDS but missing from generated help" + )