docs: generate README usage section (#1191)

## ℹ️ 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.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jens
2026-07-01 16:36:03 +02:00
committed by GitHub
parent 59185b8f6e
commit eda06334d1
7 changed files with 363 additions and 25 deletions

View File

@@ -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-schemas` — regenerates `schemas/*.json`
- `pdm run generate-config` — regenerates `docs/config.default.yaml` - `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. CI and workflows are the source of truth for the exact required checks, coverage gates, generated-artifact verification, and PR title validation.

View File

@@ -200,6 +200,7 @@ Die Nutzung erfolgt auf eigenes Risiko. Jede rechtswidrige Verwendung ist unters
## <a name="usage"></a>Usage ## <a name="usage"></a>Usage
<!-- readme-usage:generated:start -->
```console ```console
Usage: kleinanzeigen-bot COMMAND [OPTIONS] Usage: kleinanzeigen-bot COMMAND [OPTIONS]
@@ -208,13 +209,14 @@ Commands:
verify - verifies the configuration files and previews automatic price reduction outcomes verify - verifies the configuration files and previews automatic price reduction outcomes
delete - deletes ads delete - deletes ads
update - updates published 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 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-check - checks for available updates
update-content-hash recalculates each ad's content_hash based on the current ad_defaults; 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 create-config - creates a new default configuration file if one does not exist
diagnose - diagnoses browser connection issues and shows troubleshooting information diagnose - diagnoses browser connection issues and shows troubleshooting information
status - shows status overview of ads
-- --
help - displays this help (default command) help - displays this help (default command)
version - displays the application version version - displays the application version
@@ -233,23 +235,26 @@ Options:
* all: downloads all ads from your profile * all: downloads all ads from your profile
* new: downloads ads from your profile that are not locally saved yet * new: downloads ads from your profile that are not locally saved yet
* <id(s)>: provide one or several ads by ID to download, like e.g. "--ads=1,2,3" * <id(s)>: provide one or several ads by ID to download, like e.g. "--ads=1,2,3"
--ads=all|<id(s)> (extend) - specifies which ads to extend (DEFAULT: all) --ads=all|changed|<id(s)> (update) - specifies which ads to update (DEFAULT: changed)
Possible values:
* all: extend all eligible ads in your profile
* <id(s)>: 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|<id(s)> (update) - specifies which ads to update (DEFAULT: changed)
Possible values: Possible values:
* all: update all ads
* changed: only update ads that have been modified since last publication * changed: only update ads that have been modified since last publication
* <id(s)>: provide one or several ads by ID to update, like e.g. "--ads=1,2,3" * <id(s)>: provide one or several ads by ID to update, like e.g. "--ads=1,2,3"
--ads=all|<id(s)> (extend) - specifies which ads to extend (DEFAULT: all)
Possible values:
* all: extend all ads expiring within 8 days
* <id(s)>: 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' --force - alias for '--ads=all'
--keep-old - don't delete old ads on republication --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> - path to the config YAML or JSON file (does not implicitly change workspace mode) --config=<PATH> - path to the config YAML or JSON file (does not implicitly change workspace mode)
--workspace-mode=portable|xdg - overrides workspace mode for this run --workspace-mode=portable|xdg - overrides workspace mode for this run
--logfile=<PATH> - path to the logfile (DEFAULT: depends on active workspace mode) --logfile=<PATH> - path to the logfile (DEFAULT: depends on active workspace mode)
--lang=en|de - display language (STANDARD: system language if supported, otherwise English) --lang=en|de - display language (STANDARD: system language if supported, otherwise English)
-v, --verbose - enables verbose output - only useful when troubleshooting issues -v, --verbose - enables verbose output - only useful when troubleshooting issues
``` ```
<!-- readme-usage:generated:end -->
> **Note:** The output of `kleinanzeigen-bot help` is always the most up-to-date reference for available commands and options. > **Note:** The output of `kleinanzeigen-bot help` is always the most up-to-date reference for available commands and options.

View File

@@ -91,7 +91,8 @@ debug = "python -m pdb -m kleinanzeigen_bot"
# build & packaging # build & packaging
generate-schemas = "python scripts/generate_schemas.py" 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-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.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 compile.env = {PYTHONHASHSEED = "1", SOURCE_DATE_EPOCH = "0"} # https://pyinstaller.org/en/stable/advanced-topics.html#creating-a-reproducible-build

View File

@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: © Jens Bergmann and contributors # SPDX-FileCopyrightText: © Jens Bergmann and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ # 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 from __future__ import annotations
@@ -12,6 +12,7 @@ import tempfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Final 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 schema_utils import generate_schema_content
from kleinanzeigen_bot.model.ad_model import AdPartial 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 = "<generated via: pdm run generate-readme-commands>",
)
)
def main() -> None: def main() -> None:
repo_root = Path(__file__).resolve().parent.parent repo_root = Path(__file__).resolve().parent.parent
schema_diffs = get_schema_diffs(repo_root) try:
default_config_diff = get_default_config_diff(repo_root) 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."] messages:list[str] = ["Generated artifacts are not up-to-date."]
if schema_diffs: if schema_diffs:
@@ -130,13 +166,18 @@ def main() -> None:
messages.append("Outdated docs/config.default.yaml detected.") messages.append("Outdated docs/config.default.yaml detected.")
messages.append(default_config_diff) 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("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("- 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)) 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__": if __name__ == "__main__":

View File

@@ -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 ``<!-- readme-usage:generated:start -->`` and
``<!-- readme-usage:generated:end -->`` 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 = "<!-- readme-usage:generated:start -->"
END_MARKER:str = "<!-- readme-usage:generated:end -->"
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()

View File

@@ -117,19 +117,20 @@ def _help_executable() -> str:
return "python -m kleinanzeigen_bot" return "python -m kleinanzeigen_bot"
def _help_text() -> str: def help_text(*, executable:str | None = None, language:str | None = None) -> str:
exe = _help_executable() exe = executable if executable is not None else _help_executable()
if get_current_locale().language == "de": lang = language if language is not None else get_current_locale().language
if lang == "de":
return textwrap.dedent( return textwrap.dedent(
f"""\ f"""\
Verwendung: {colorama.Fore.LIGHTMAGENTA_EX}{exe} BEFEHL [OPTIONEN]{colorama.Style.RESET_ALL} Verwendung: {colorama.Fore.LIGHTMAGENTA_EX}{exe} BEFEHL [OPTIONEN]{colorama.Style.RESET_ALL}
Befehle: Befehle:
publish - (Wieder-)Veröffentlicht Anzeigen publish - (Wieder-)Veröffentlicht Anzeigen
verify - Überprüft die Konfigurationsdateien verify - Überprüft die Konfigurationsdateien und zeigt Preissenkungsvorschauen an
delete - Löscht Anzeigen delete - Löscht Anzeigen
update - Aktualisiert bestehende 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 download - Lädt eine oder mehrere Anzeigen herunter
update-check - Prüft auf verfügbare Updates update-check - Prüft auf verfügbare Updates
update-content-hash - Berechnet den content_hash aller Anzeigen anhand der aktuellen ad_defaults neu; 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: Mögliche Werte:
* all: Verlängert alle Anzeigen, die innerhalb von 8 Tagen ablaufen * all: Verlängert alle Anzeigen, die innerhalb von 8 Tagen ablaufen
* <id(s)>: Gibt bestimmte Anzeigen-IDs an, z. B. "--ads=1,2,3" * <id(s)>: 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' --force - Alias für '--ads=all'
--keep-old - Verhindert das Löschen alter Anzeigen bei erneuter Veröffentlichung --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) --preserve-local-settings - Erzwingt das Beibehalten lokaler Einstellungen bei erneutem Download (überschreibt config-Wert false)
@@ -183,10 +185,10 @@ def _help_text() -> str:
Commands: Commands:
publish - (re-)publishes ads publish - (re-)publishes ads
verify - verifies the configuration files verify - verifies the configuration files and previews automatic price reduction outcomes
delete - deletes ads delete - deletes ads
update - updates published 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 download - downloads one or multiple ads
update-check - checks for available updates update-check - checks for available updates
update-content-hash recalculates each ad's content_hash based on the current ad_defaults; 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: Possible values:
* all: extend all ads expiring within 8 days * all: extend all ads expiring within 8 days
* <id(s)>: specify ad IDs to extend, e.g. "--ads=1,2,3" * <id(s)>: 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' --force - alias for '--ads=all'
--keep-old - don't delete old ads on republication --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) --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: def show_help() -> None:
print(_help_text()) print(help_text())
def parse_args(args:Sequence[str]) -> ParsedArgs: def parse_args(args:Sequence[str]) -> ParsedArgs:

View File

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