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"
+ )