Public Access
Post-merge / detect-type (push) Successful in 19s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 25s
Post-merge / sync-wiki (push) Successful in 32s
Post-merge / release (push) Successful in 43s
Post-merge / publish (push) Successful in 21s
Post-merge / badges (push) Successful in 38s
398 lines
19 KiB
Python
398 lines
19 KiB
Python
"""Unit tests for devx.ci.doc_coverage."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from devx.ci.doc_coverage import (
|
|
check_command_documented,
|
|
check_module_documented,
|
|
extract_cli_commands,
|
|
main,
|
|
)
|
|
|
|
# Path to devx's own source directory (for testing)
|
|
DEVX_SRC_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "devx"
|
|
|
|
|
|
class TestExtractCliCommands:
|
|
def test_extracts_commands(self) -> None:
|
|
commands = extract_cli_commands(DEVX_SRC_DIR)
|
|
# devx CLI has commands under ci, tools, and molecule groups
|
|
assert "auto-merge" in commands
|
|
assert "release" in commands
|
|
assert "publish" in commands
|
|
assert "setup" in commands
|
|
assert "install-tools" in commands
|
|
|
|
def test_returns_list(self) -> None:
|
|
commands = extract_cli_commands(DEVX_SRC_DIR)
|
|
assert isinstance(commands, list)
|
|
assert len(commands) > 0
|
|
|
|
def test_no_cli_file(self, tmp_path: Path) -> None:
|
|
"""Returns empty list when CLI file doesn't exist."""
|
|
commands = extract_cli_commands(tmp_path)
|
|
assert commands == []
|
|
|
|
def test_def_fallback_no_explicit_name(self, tmp_path: Path) -> None:
|
|
"""When a command decorator has no explicit name, falls back to the def name."""
|
|
fake_cli = tmp_path / "cli.py"
|
|
fake_cli.write_text("@click.group()\ndef cli():\n pass\n@cli.command()\ndef my_command():\n pass\n")
|
|
commands = extract_cli_commands(tmp_path)
|
|
assert "my_command" in commands
|
|
|
|
def test_command_decorator_no_def_fallback(self, tmp_path: Path) -> None:
|
|
"""When a command decorator has no name and no following def, it is skipped."""
|
|
fake_cli = tmp_path / "cli.py"
|
|
# The last @cli.command() has no explicit name and no def statement after it
|
|
fake_cli.write_text(
|
|
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
|
|
)
|
|
commands = extract_cli_commands(tmp_path)
|
|
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
|
|
assert "real_cmd" in commands
|
|
assert "pass" not in commands
|
|
|
|
def test_command_with_explicit_name_param(self, tmp_path: Path) -> None:
|
|
"""When a command uses name="explicit-name", that name is extracted."""
|
|
fake_cli = tmp_path / "cli.py"
|
|
fake_cli.write_text(
|
|
'@click.group()\ndef cli():\n pass\n@cli.command(name="my-command")\ndef my_command():\n pass\n'
|
|
)
|
|
commands = extract_cli_commands(tmp_path)
|
|
assert "my-command" in commands
|
|
assert "my_command" not in commands
|
|
|
|
def test_command_with_help_kwarg_uses_def_name(self, tmp_path: Path) -> None:
|
|
"""When a command uses help= kwarg but no name=, falls back to def name."""
|
|
fake_cli = tmp_path / "cli.py"
|
|
fake_cli.write_text(
|
|
"@click.group()\ndef cli():\n pass\n"
|
|
'@cli.command(help="Do something useful")\ndef do_something():\n pass\n'
|
|
)
|
|
commands = extract_cli_commands(tmp_path)
|
|
assert "do_something" in commands
|
|
assert "Do something useful" not in commands
|
|
|
|
def test_command_with_help_translation_uses_def_name(self, tmp_path: Path) -> None:
|
|
"""When a command uses help=_() translation, falls back to def name."""
|
|
fake_cli = tmp_path / "cli.py"
|
|
fake_cli.write_text(
|
|
"@click.group()\ndef cli():\n pass\n"
|
|
'@cli.command(help=_("Install and configure things"))\ndef install():\n pass\n'
|
|
)
|
|
commands = extract_cli_commands(tmp_path)
|
|
assert "install" in commands
|
|
assert "Install and configure things" not in commands
|
|
|
|
|
|
class TestCheckCommandDocumented:
|
|
def test_finds_command_in_heading(self) -> None:
|
|
content = "## auto-merge\n\nAuto-merge PR."
|
|
assert check_command_documented("auto-merge", content) is True
|
|
|
|
def test_finds_command_in_code_block(self) -> None:
|
|
content = "```bash\ndevx ci release --dry-run\n```"
|
|
assert check_command_documented("release", content) is True
|
|
|
|
def test_finds_command_with_devx_prefix(self) -> None:
|
|
content = "Use `devx tools setup` to install."
|
|
assert check_command_documented("setup", content) is True
|
|
|
|
def test_missing_command(self) -> None:
|
|
content = "## Other stuff\n\nNo commands here."
|
|
assert check_command_documented("release", content) is False
|
|
|
|
|
|
class TestCheckModuleDocumented:
|
|
def test_finds_module(self) -> None:
|
|
content = "The cli.py module handles..."
|
|
assert check_module_documented("cli.py", content) is True
|
|
|
|
def test_missing_module(self) -> None:
|
|
content = "No modules mentioned."
|
|
assert check_module_documented("cli.py", content) is False
|
|
|
|
|
|
class TestMain:
|
|
def test_all_present(self, tmp_path: Path) -> None:
|
|
"""When all docs exist and cover all commands/modules, exit 0."""
|
|
docs = tmp_path / "docs"
|
|
(docs / "user").mkdir(parents=True)
|
|
(docs / "tech").mkdir(parents=True)
|
|
src = tmp_path / "src" / "devx"
|
|
src.mkdir(parents=True)
|
|
(src / "ci").mkdir()
|
|
(src / "__init__.py").write_text("")
|
|
(src / "ci" / "__init__.py").write_text("")
|
|
# Create a fake cli.py with some commands
|
|
(src / "cli.py").write_text(
|
|
"@click.group()\ndef cli():\n pass\n"
|
|
"@cli.command('release')\ndef release():\n pass\n"
|
|
"@cli.command('setup')\ndef setup():\n pass\n"
|
|
)
|
|
# Create a fake module and CI script
|
|
(src / "config.py").write_text("# config module")
|
|
(src / "ci" / "auto_merge.py").write_text("# auto_merge script")
|
|
# Write cli-commands.md with all commands
|
|
cli_content = "## release\n\n## setup\n"
|
|
(docs / "user" / "cli-commands.md").write_text(cli_content)
|
|
# Write architecture.md with all modules
|
|
(docs / "tech" / "architecture.md").write_text("config.py")
|
|
# Write ci-cd-workflow.md with all scripts
|
|
(docs / "tech" / "ci-cd-workflow.md").write_text("auto_merge.py")
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
|
assert result.exit_code == 0
|
|
assert "100%" in result.output
|
|
|
|
def test_missing_docs_fail(self, tmp_path: Path) -> None:
|
|
"""When docs are missing and --fail-on-missing is set, exit 1."""
|
|
docs = tmp_path / "docs"
|
|
(docs / "user").mkdir(parents=True)
|
|
(docs / "tech").mkdir(parents=True)
|
|
src = tmp_path / "src" / "devx"
|
|
src.mkdir(parents=True)
|
|
(src / "ci").mkdir()
|
|
(src / "__init__.py").write_text("")
|
|
(src / "ci" / "__init__.py").write_text("")
|
|
(src / "cli.py").write_text(
|
|
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
|
)
|
|
(src / "config.py").write_text("# config")
|
|
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
|
|
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
|
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
|
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src), "--fail-on-missing"])
|
|
assert result.exit_code == 1
|
|
|
|
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
|
|
"""Without --fail-on-missing, missing docs only warn (exit 0)."""
|
|
docs = tmp_path / "docs"
|
|
(docs / "user").mkdir(parents=True)
|
|
(docs / "tech").mkdir(parents=True)
|
|
src = tmp_path / "src" / "devx"
|
|
src.mkdir(parents=True)
|
|
(src / "ci").mkdir()
|
|
(src / "__init__.py").write_text("")
|
|
(src / "ci" / "__init__.py").write_text("")
|
|
(src / "cli.py").write_text(
|
|
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
|
)
|
|
(src / "config.py").write_text("# config")
|
|
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
|
|
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
|
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
|
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
|
assert result.exit_code == 0
|
|
assert "MISSING" in result.output
|
|
|
|
def test_auto_detect_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""When src/ doesn't exist but scripts/ does, auto-detect it."""
|
|
monkeypatch.chdir(tmp_path)
|
|
docs = tmp_path / "docs"
|
|
(docs / "user").mkdir(parents=True)
|
|
(docs / "tech").mkdir(parents=True)
|
|
scripts = tmp_path / "scripts"
|
|
scripts.mkdir()
|
|
(scripts / "cli.py").write_text(
|
|
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
|
)
|
|
(scripts / "config.py").write_text("# config")
|
|
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
|
(docs / "tech" / "architecture.md").write_text("config.py")
|
|
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
|
assert result.exit_code == 0
|
|
|
|
def test_no_source_dir_falls_back_to_required(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""When no source dir exists, falls back to REQUIRED_MODULES/SCRIPTS."""
|
|
monkeypatch.chdir(tmp_path)
|
|
docs = tmp_path / "docs"
|
|
(docs / "user").mkdir(parents=True)
|
|
(docs / "tech").mkdir(parents=True)
|
|
(docs / "user" / "cli-commands.md").write_text("")
|
|
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
|
|
|
|
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
|
|
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
|
# No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists
|
|
assert result.exit_code == 0
|
|
|
|
def test_ci_scripts_dir_empty_skips_ci_checks(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""When --ci-scripts-dir is empty string, CI script checks are skipped."""
|
|
monkeypatch.chdir(tmp_path)
|
|
docs = tmp_path / "docs"
|
|
(docs / "user").mkdir(parents=True)
|
|
(docs / "tech").mkdir(parents=True)
|
|
src = tmp_path / "src" / "devx"
|
|
src.mkdir(parents=True)
|
|
(src / "__init__.py").write_text("")
|
|
(src / "cli.py").write_text(
|
|
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
|
)
|
|
(src / "config.py").write_text("# config")
|
|
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
|
(docs / "tech" / "architecture.md").write_text("config.py")
|
|
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", ""])
|
|
assert result.exit_code == 0
|
|
assert "100%" in result.output
|
|
# Should not mention any CI scripts
|
|
assert "MISSING" not in result.output or "CI script" not in result.output
|
|
|
|
def test_ci_scripts_dir_explicit_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""When --ci-scripts-dir points to a directory, scripts are detected from there."""
|
|
monkeypatch.chdir(tmp_path)
|
|
docs = tmp_path / "docs"
|
|
(docs / "user").mkdir(parents=True)
|
|
(docs / "tech").mkdir(parents=True)
|
|
src = tmp_path / "src" / "myapp"
|
|
src.mkdir(parents=True)
|
|
(src / "__init__.py").write_text("")
|
|
(src / "cli.py").write_text(
|
|
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
|
)
|
|
ci_dir = tmp_path / "ci"
|
|
ci_dir.mkdir()
|
|
(ci_dir / "my_script.py").write_text("# my script")
|
|
(ci_dir / "__init__.py").write_text("")
|
|
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
|
(docs / "tech" / "architecture.md").write_text("")
|
|
(docs / "tech" / "ci-cd-workflow.md").write_text("my_script.py")
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", str(ci_dir)]
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "my_script.py" in result.output
|
|
assert "OK: my_script.py" in result.output
|
|
|
|
def test_ci_scripts_dir_nonexistent_skips(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""When --ci-scripts-dir points to a non-existent path, CI checks are skipped."""
|
|
monkeypatch.chdir(tmp_path)
|
|
docs = tmp_path / "docs"
|
|
(docs / "user").mkdir(parents=True)
|
|
(docs / "tech").mkdir(parents=True)
|
|
src = tmp_path / "src" / "myapp"
|
|
src.mkdir(parents=True)
|
|
(src / "__init__.py").write_text("")
|
|
(src / "cli.py").write_text(
|
|
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
|
)
|
|
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
|
(docs / "tech" / "architecture.md").write_text("")
|
|
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", "/nonexistent"]
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "100%" in result.output
|
|
|
|
def test_config_from_pyproject_ci_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""When [tool.devx.doc_coverage] ci_scripts_dir is set in pyproject.toml, it's used."""
|
|
monkeypatch.chdir(tmp_path)
|
|
docs = tmp_path / "docs"
|
|
(docs / "user").mkdir(parents=True)
|
|
(docs / "tech").mkdir(parents=True)
|
|
src = tmp_path / "src" / "myapp"
|
|
src.mkdir(parents=True)
|
|
(src / "__init__.py").write_text("")
|
|
(src / "cli.py").write_text(
|
|
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
|
)
|
|
(src / "config.py").write_text("# config")
|
|
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
|
(docs / "tech" / "architecture.md").write_text("config.py")
|
|
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
|
# Write pyproject.toml with ci_scripts_dir = ""
|
|
(tmp_path / "pyproject.toml").write_text('[tool.devx.doc_coverage]\nci_scripts_dir = ""\n')
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
|
assert result.exit_code == 0
|
|
assert "100%" in result.output
|
|
|
|
def test_config_from_pyproject_docs_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""When [tool.devx.doc_coverage] docs_dir is set in pyproject.toml, it's used."""
|
|
monkeypatch.chdir(tmp_path)
|
|
custom_docs = tmp_path / "custom-docs"
|
|
(custom_docs / "user").mkdir(parents=True)
|
|
(custom_docs / "tech").mkdir(parents=True)
|
|
src = tmp_path / "src" / "myapp"
|
|
src.mkdir(parents=True)
|
|
(src / "__init__.py").write_text("")
|
|
(src / "cli.py").write_text(
|
|
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
|
)
|
|
(src / "config.py").write_text("# config")
|
|
(custom_docs / "user" / "cli-commands.md").write_text("## release\n")
|
|
(custom_docs / "tech" / "architecture.md").write_text("config.py")
|
|
(custom_docs / "tech" / "ci-cd-workflow.md").write_text("")
|
|
# Write pyproject.toml with custom docs_dir
|
|
(tmp_path / "pyproject.toml").write_text(
|
|
f'[tool.devx.doc_coverage]\ndocs_dir = "{custom_docs}"\nci_scripts_dir = ""\n'
|
|
)
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--source-dir", str(src)])
|
|
assert result.exit_code == 0
|
|
assert "100%" in result.output
|
|
|
|
def test_config_from_pyproject_source_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""When [tool.devx.doc_coverage] source_dir is set in pyproject.toml, it's used."""
|
|
monkeypatch.chdir(tmp_path)
|
|
docs = tmp_path / "docs"
|
|
(docs / "user").mkdir(parents=True)
|
|
(docs / "tech").mkdir(parents=True)
|
|
custom_src = tmp_path / "custom-src" / "myapp"
|
|
custom_src.mkdir(parents=True)
|
|
(custom_src / "__init__.py").write_text("")
|
|
(custom_src / "cli.py").write_text(
|
|
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
|
)
|
|
(custom_src / "config.py").write_text("# config")
|
|
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
|
(docs / "tech" / "architecture.md").write_text("config.py")
|
|
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
|
# Write pyproject.toml with custom source_dir
|
|
(tmp_path / "pyproject.toml").write_text(
|
|
f'[tool.devx.doc_coverage]\nsource_dir = "{custom_src}"\nci_scripts_dir = ""\n'
|
|
)
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
|
assert result.exit_code == 0
|
|
assert "100%" in result.output
|
|
|
|
def test_config_doc_coverage_not_dict(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""When [tool.devx.doc_coverage] is not a dict, falls back to defaults."""
|
|
monkeypatch.chdir(tmp_path)
|
|
docs = tmp_path / "docs"
|
|
(docs / "user").mkdir(parents=True)
|
|
(docs / "tech").mkdir(parents=True)
|
|
src = tmp_path / "src" / "myapp"
|
|
src.mkdir(parents=True)
|
|
(src / "__init__.py").write_text("")
|
|
(src / "cli.py").write_text(
|
|
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
|
)
|
|
(src / "config.py").write_text("# config")
|
|
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
|
(docs / "tech" / "architecture.md").write_text("config.py")
|
|
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
|
# Write pyproject.toml with doc_coverage as a non-dict value
|
|
(tmp_path / "pyproject.toml").write_text('[tool.devx]\ndoc_coverage = "not-a-dict"\n')
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
|
assert result.exit_code == 0
|
|
assert "100%" in result.output
|