ci: Add DevOps quality assurance with pre-commit hooks and GitHub Actions

- Add pre-commit hooks: Ruff lint/format, Bandit security scan, YAML/TOML validation
- Add GitHub Actions CI workflow: lint, security, test, and build jobs
- Configure Ruff and Bandit in pyproject.toml
- Add pytest test suite for build_epub.py (25 tests)
- Fix code issues: exception chaining, httpx timeout, formatting
- Add requirements.txt and requirements-dev.txt
This commit is contained in:
Luong NGUYEN
2025-12-10 23:49:52 +01:00
parent 8b38c22f64
commit 540508f392
15 changed files with 1855 additions and 413 deletions
+106
View File
@@ -0,0 +1,106 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# Cancel in-progress runs for the same branch
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint & Format
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Set up Python
run: uv python install 3.11
- name: Install Ruff
run: uv pip install --system ruff
- name: Ruff Format Check
run: ruff format --check scripts/
- name: Ruff Lint Check
run: ruff check scripts/
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Set up Python
run: uv python install 3.11
- name: Install Bandit
run: uv pip install --system "bandit[toml]"
- name: Run Bandit Security Scan
run: bandit -c pyproject.toml -r scripts/ --exclude scripts/tests/
test:
name: Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Set up Python
run: uv python install 3.11
- name: Install dependencies
run: |
uv pip install --system -r requirements.txt
uv pip install --system pytest pytest-asyncio
- name: Run Tests
run: pytest scripts/tests/ -v --tb=short
build:
name: Build EPUB
runs-on: ubuntu-latest
needs: [lint, security, test]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Build EPUB
run: uv run scripts/build_epub.py
- name: Verify EPUB Created
run: |
if [ -f claude-howto-guide.epub ]; then
echo "EPUB built successfully"
ls -lh claude-howto-guide.epub
else
echo "EPUB file not found!"
exit 1
fi
- name: Upload EPUB Artifact
uses: actions/upload-artifact@v4
with:
name: claude-howto-guide-epub
path: claude-howto-guide.epub
retention-days: 7
+15 -1
View File
@@ -48,4 +48,18 @@ yarn-error.log*
blog-posts/
# EPUB files in root directory
/*.epub
/*.epub
# Python virtual environment
.venv/
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
.pytest_cache/
.mypy_cache/
*.egg-info/
dist/
build/
+54
View File
@@ -0,0 +1,54 @@
# Pre-commit hooks for claude-howto project
# Run `pre-commit install` to set up hooks
# Run `pre-commit run --all-files` to check all files
default_language_version:
python: python3.11
repos:
# Ruff - Fast Python linter and formatter
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.2
hooks:
# Ruff linter
- id: ruff
name: ruff-lint
args: [--fix, --exit-non-zero-on-fix]
types_or: [python, pyi]
files: ^scripts/
# Ruff formatter (replaces black)
- id: ruff-format
name: ruff-format
types_or: [python, pyi]
files: ^scripts/
# Bandit - Security linter
- repo: https://github.com/PyCQA/bandit
rev: 1.7.10
hooks:
- id: bandit
name: bandit-security
args: [-c, pyproject.toml]
additional_dependencies: ["bandit[toml]"]
types: [python]
files: ^scripts/
exclude: ^scripts/tests/
# Standard pre-commit hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-yaml
name: check-yaml
args: [--allow-multiple-documents]
- id: check-toml
name: check-toml
- id: end-of-file-fixer
name: fix-end-of-file
- id: trailing-whitespace
name: fix-trailing-whitespace
- id: check-added-large-files
name: check-large-files
args: [--maxkb=1000]
- id: check-merge-conflict
name: check-merge-conflict
@@ -2,31 +2,33 @@
import re
import sys
def analyze_code_metrics(code):
"""Analyze code for common metrics."""
# Count functions
functions = len(re.findall(r'^def\s+\w+', code, re.MULTILINE))
functions = len(re.findall(r"^def\s+\w+", code, re.MULTILINE))
# Count classes
classes = len(re.findall(r'^class\s+\w+', code, re.MULTILINE))
classes = len(re.findall(r"^class\s+\w+", code, re.MULTILINE))
# Average line length
lines = code.split('\n')
lines = code.split("\n")
avg_length = sum(len(l) for l in lines) / len(lines) if lines else 0
# Estimate complexity
complexity = len(re.findall(r'\b(if|elif|else|for|while|and|or)\b', code))
complexity = len(re.findall(r"\b(if|elif|else|for|while|and|or)\b", code))
return {
'functions': functions,
'classes': classes,
'avg_line_length': avg_length,
'complexity_score': complexity
"functions": functions,
"classes": classes,
"avg_line_length": avg_length,
"complexity_score": complexity,
}
if __name__ == '__main__':
with open(sys.argv[1], 'r') as f:
if __name__ == "__main__":
with open(sys.argv[1]) as f:
code = f.read()
metrics = analyze_code_metrics(code)
for key, value in metrics.items():
@@ -6,14 +6,14 @@ Helps identify if refactoring actually simplifies code structure.
import re
import sys
from typing import Dict, Tuple
class ComplexityAnalyzer:
"""Analyze code complexity metrics."""
def __init__(self, code: str):
self.code = code
self.lines = code.split('\n')
self.lines = code.split("\n")
def calculate_cyclomatic_complexity(self) -> int:
"""
@@ -24,13 +24,13 @@ class ComplexityAnalyzer:
# Count decision points
decision_patterns = [
r'\bif\b',
r'\belif\b',
r'\bfor\b',
r'\bwhile\b',
r'\bexcept\b',
r'\band\b(?!$)',
r'\bor\b(?!$)'
r"\bif\b",
r"\belif\b",
r"\bfor\b",
r"\bwhile\b",
r"\bexcept\b",
r"\band\b(?!$)",
r"\bor\b(?!$)",
]
for pattern in decision_patterns:
@@ -49,10 +49,10 @@ class ComplexityAnalyzer:
for line in self.lines:
# Track nesting depth
if re.search(r'^\s*(if|for|while|def|class|try)\b', line):
if re.search(r"^\s*(if|for|while|def|class|try)\b", line):
nesting_depth += 1
cognitive += nesting_depth
elif re.search(r'^\s*(elif|else|except|finally)\b', line):
elif re.search(r"^\s*(elif|else|except|finally)\b", line):
cognitive += nesting_depth
# Reduce nesting when unindenting
@@ -74,28 +74,37 @@ class ComplexityAnalyzer:
cognitive = self.calculate_cognitive_complexity()
# Simplified MI calculation
mi = 171 - 5.2 * (cyclomatic / lines) - 0.23 * (cognitive) - 16.2 * (lines / 1000)
mi = (
171
- 5.2 * (cyclomatic / lines)
- 0.23 * (cognitive)
- 16.2 * (lines / 1000)
)
return max(0, min(100, mi))
def get_complexity_report(self) -> Dict:
def get_complexity_report(self) -> dict:
"""Generate comprehensive complexity report."""
return {
'cyclomatic_complexity': self.calculate_cyclomatic_complexity(),
'cognitive_complexity': self.calculate_cognitive_complexity(),
'maintainability_index': round(self.calculate_maintainability_index(), 2),
'lines_of_code': len(self.lines),
'avg_line_length': round(sum(len(l) for l in self.lines) / len(self.lines), 2) if self.lines else 0
"cyclomatic_complexity": self.calculate_cyclomatic_complexity(),
"cognitive_complexity": self.calculate_cognitive_complexity(),
"maintainability_index": round(self.calculate_maintainability_index(), 2),
"lines_of_code": len(self.lines),
"avg_line_length": round(
sum(len(l) for l in self.lines) / len(self.lines), 2
)
if self.lines
else 0,
}
def compare_files(before_file: str, after_file: str) -> None:
"""Compare complexity metrics between two code versions."""
with open(before_file, 'r') as f:
with open(before_file) as f:
before_code = f.read()
with open(after_file, 'r') as f:
with open(after_file) as f:
after_code = f.read()
before_analyzer = ComplexityAnalyzer(before_code)
@@ -123,10 +132,16 @@ def compare_files(before_file: str, after_file: str) -> None:
print(f" Avg Line Length: {after_metrics['avg_line_length']}")
print("\nCHANGES:")
cyclomatic_change = after_metrics['cyclomatic_complexity'] - before_metrics['cyclomatic_complexity']
cognitive_change = after_metrics['cognitive_complexity'] - before_metrics['cognitive_complexity']
mi_change = after_metrics['maintainability_index'] - before_metrics['maintainability_index']
loc_change = after_metrics['lines_of_code'] - before_metrics['lines_of_code']
cyclomatic_change = (
after_metrics["cyclomatic_complexity"] - before_metrics["cyclomatic_complexity"]
)
cognitive_change = (
after_metrics["cognitive_complexity"] - before_metrics["cognitive_complexity"]
)
mi_change = (
after_metrics["maintainability_index"] - before_metrics["maintainability_index"]
)
loc_change = after_metrics["lines_of_code"] - before_metrics["lines_of_code"]
print(f" Cyclomatic Complexity: {cyclomatic_change:+d}")
print(f" Cognitive Complexity: {cognitive_change:+d}")
@@ -151,7 +166,7 @@ def compare_files(before_file: str, after_file: str) -> None:
print("=" * 60)
if __name__ == '__main__':
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: python compare-complexity.py <before_file> <after_file>")
sys.exit(1)
+12 -10
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
import ast
import json
from typing import Dict, List
class APIDocExtractor(ast.NodeVisitor):
"""Extract API documentation from Python source code."""
@@ -11,13 +10,13 @@ class APIDocExtractor(ast.NodeVisitor):
def visit_FunctionDef(self, node):
"""Extract function documentation."""
if node.name.startswith('get_') or node.name.startswith('post_'):
if node.name.startswith("get_") or node.name.startswith("post_"):
doc = ast.get_docstring(node)
endpoint = {
'name': node.name,
'docstring': doc,
'params': [arg.arg for arg in node.args.args],
'returns': self._extract_return_type(node)
"name": node.name,
"docstring": doc,
"params": [arg.arg for arg in node.args.args],
"returns": self._extract_return_type(node),
}
self.endpoints.append(endpoint)
self.generic_visit(node)
@@ -28,7 +27,8 @@ class APIDocExtractor(ast.NodeVisitor):
return ast.unparse(node.returns)
return "Any"
def generate_markdown_docs(endpoints: List[Dict]) -> str:
def generate_markdown_docs(endpoints: list[dict]) -> str:
"""Generate markdown documentation from endpoints."""
docs = "# API Documentation\n\n"
@@ -41,9 +41,11 @@ def generate_markdown_docs(endpoints: List[Dict]) -> str:
return docs
if __name__ == '__main__':
if __name__ == "__main__":
import sys
with open(sys.argv[1], 'r') as f:
with open(sys.argv[1]) as f:
tree = ast.parse(f.read())
extractor = APIDocExtractor()
+23 -1
View File
@@ -2,6 +2,14 @@
# Claude How To
## Contributors
Thanks to everyone who has contributed to this project!
| Contributor | PRs |
|-------------|-----|
| [wjhrdy](https://github.com/wjhrdy) | [#1 - add a tool to create an epub](https://github.com/luongnv89/claude-howto/pull/1) |
Complete collection of examples for some important Claude Code features and concepts.
## Quick Navigation
@@ -667,6 +675,20 @@ These examples are provided as-is for educational purposes. Adapt and use them f
---
**Last Updated**: November 2025
## EPUB Generation
Want to read this guide offline? Generate an EPUB ebook:
```bash
uv run scripts/build_epub.py
```
This creates `claude-howto-guide.epub` with all content, including rendered Mermaid diagrams.
See [scripts/README.md](scripts/README.md) for more options.
---
**Last Updated**: December 2025
**Claude Code Version**: 1.0+
**Compatible Models**: Sonnet 4.5, Opus 4.1, Haiku 4.5
+100
View File
@@ -0,0 +1,100 @@
[project]
name = "claude-howto"
version = "1.0.0"
description = "Claude Code How-To Guide with EPUB builder"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
dependencies = [
"ebooklib",
"markdown",
"beautifulsoup4",
"httpx",
"pillow",
"tenacity",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-asyncio>=0.21",
]
[tool.pytest.ini_options]
testpaths = ["scripts/tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
# =============================================================================
# Ruff Configuration
# =============================================================================
[tool.ruff]
target-version = "py310"
line-length = 88
include = ["scripts/**/*.py"]
exclude = [
".git",
".venv",
"__pycache__",
".pytest_cache",
"*.egg-info",
]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort (import sorting)
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
"RUF", # Ruff-specific rules
"PTH", # flake8-use-pathlib
"PL", # Pylint
"PERF", # Perflint
]
ignore = [
"E501", # Line too long (handled by formatter)
"PLR0913", # Too many arguments
"PLR2004", # Magic value comparison
"PLR0915", # Too many statements
"PERF203", # try-except in loop (acceptable for error handling)
"PERF403", # dict comprehension (readability preference)
"TC003", # Type-checking imports (not critical)
"PLC0415", # Import not at top level (acceptable for lazy imports)
"RUF005", # Collection concatenation (readability preference)
]
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.isort]
known-first-party = ["build_epub"]
force-single-line = false
combine-as-imports = true
[tool.ruff.lint.per-file-ignores]
"scripts/tests/*.py" = ["S101", "PLR2004"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
docstring-code-format = true
# =============================================================================
# Bandit Configuration
# =============================================================================
[tool.bandit]
targets = ["scripts"]
exclude_dirs = ["scripts/tests", ".venv", "__pycache__"]
skips = ["B101", "B113"] # B113: httpx timeout false positive (timeout is set)
+11
View File
@@ -0,0 +1,11 @@
# Development dependencies (includes core dependencies)
-r requirements.txt
# Testing
pytest>=7.0
pytest-asyncio>=0.21
# Code Quality
pre-commit>=3.6.0
ruff>=0.8.0
bandit[toml]>=1.7.7
+7
View File
@@ -0,0 +1,7 @@
# Core dependencies for build_epub.py
ebooklib
markdown
beautifulsoup4
httpx
pillow
tenacity
+115
View File
@@ -0,0 +1,115 @@
# EPUB Builder Script
Build an EPUB ebook from the Claude How-To markdown files.
## Features
- Organizes chapters by folder structure (01-slash-commands, 02-memory, etc.)
- Renders Mermaid diagrams as PNG images via Kroki.io API
- Async concurrent fetching - renders all diagrams in parallel
- Generates a cover image from the project logo
- Converts internal markdown links to EPUB chapter references
- Strict error mode - fails if any diagram cannot be rendered
## Requirements
- Python 3.10+
- [uv](https://github.com/astral-sh/uv)
- Internet connection for Mermaid diagram rendering
## Quick Start
```bash
# Simplest way - uv handles everything
uv run scripts/build_epub.py
```
## Development Setup
```bash
# Create virtual environment
uv venv
# Activate and install dependencies
source .venv/bin/activate
uv pip install -r requirements-dev.txt
# Run tests
pytest scripts/tests/ -v
# Run the script
python scripts/build_epub.py
```
## Command-Line Options
```
usage: build_epub.py [-h] [--root ROOT] [--output OUTPUT] [--verbose]
[--timeout TIMEOUT] [--max-concurrent MAX_CONCURRENT]
options:
-h, --help show this help message and exit
--root, -r ROOT Root directory (default: repo root)
--output, -o OUTPUT Output path (default: claude-howto-guide.epub)
--verbose, -v Enable verbose logging
--timeout TIMEOUT API timeout in seconds (default: 30)
--max-concurrent N Max concurrent requests (default: 10)
```
## Examples
```bash
# Build with verbose output
uv run scripts/build_epub.py --verbose
# Custom output location
uv run scripts/build_epub.py --output ~/Desktop/claude-guide.epub
# Limit concurrent requests (if rate-limited)
uv run scripts/build_epub.py --max-concurrent 5
```
## Output
Creates `claude-howto-guide.epub` in the repository root directory.
The EPUB includes:
- Cover image with project logo
- Table of contents with nested sections
- All markdown content converted to EPUB-compatible HTML
- Mermaid diagrams rendered as PNG images
## Running Tests
```bash
# With virtual environment
source .venv/bin/activate
pytest scripts/tests/ -v
# Or with uv directly
uv run --with pytest --with pytest-asyncio \
--with ebooklib --with markdown --with beautifulsoup4 \
--with httpx --with pillow --with tenacity \
pytest scripts/tests/ -v
```
## Dependencies
Managed via PEP 723 inline script metadata:
| Package | Purpose |
|---------|---------|
| `ebooklib` | EPUB generation |
| `markdown` | Markdown to HTML conversion |
| `beautifulsoup4` | HTML parsing |
| `httpx` | Async HTTP client |
| `pillow` | Cover image generation |
| `tenacity` | Retry logic |
## Troubleshooting
**Build fails with network error**: Check internet connectivity and Kroki.io status. Try `--timeout 60`.
**Rate limiting**: Reduce concurrent requests with `--max-concurrent 3`.
**Missing logo**: The script generates a text-only cover if `claude-howto-logo.png` is not found.
+887 -366
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
# Tests for build_epub module
+58
View File
@@ -0,0 +1,58 @@
"""Pytest configuration and shared fixtures for EPUB builder tests."""
from __future__ import annotations
import logging
import sys
from pathlib import Path
import pytest
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from build_epub import BuildState, EPUBConfig, setup_logging
@pytest.fixture
def tmp_project(tmp_path: Path) -> Path:
"""Create a minimal project structure for testing."""
# Create root markdown file
readme = tmp_path / "README.md"
readme.write_text("# Test Project\n\nThis is a test.")
# Create a chapter directory
chapter_dir = tmp_path / "01-test-chapter"
chapter_dir.mkdir()
(chapter_dir / "README.md").write_text("# Chapter Overview\n\nOverview content.")
(chapter_dir / "section.md").write_text("# Section\n\nSection content.")
# Create a proper PNG logo using PIL
from PIL import Image as PILImage
logo_path = tmp_path / "claude-howto-logo.png"
img = PILImage.new("RGB", (100, 100), color=(26, 26, 46))
img.save(logo_path, "PNG")
return tmp_path
@pytest.fixture
def config(tmp_project: Path) -> EPUBConfig:
"""Create a test configuration."""
return EPUBConfig(
root_path=tmp_project,
output_path=tmp_project / "test.epub",
)
@pytest.fixture
def state() -> BuildState:
"""Create a fresh build state."""
return BuildState()
@pytest.fixture
def logger() -> logging.Logger:
"""Create a test logger."""
return setup_logging(verbose=False)
+414
View File
@@ -0,0 +1,414 @@
"""Tests for the EPUB builder module."""
from __future__ import annotations
import logging
from pathlib import Path
from unittest.mock import patch
import pytest
# Fixtures are imported from conftest.py automatically by pytest
# Import from parent directory (handled by conftest.py sys.path)
from build_epub import (
BuildState,
ChapterCollector,
EPUBConfig,
ValidationError,
create_chapter_html,
extract_all_mermaid_blocks,
get_chapter_order,
sanitize_mermaid,
setup_logging,
validate_inputs,
)
# =============================================================================
# BuildState Tests
# =============================================================================
class TestBuildState:
"""Tests for BuildState dataclass."""
def test_initial_state(self, state: BuildState) -> None:
"""Test that initial state is empty."""
assert state.mermaid_counter == 0
assert len(state.mermaid_cache) == 0
assert len(state.mermaid_added_to_book) == 0
assert len(state.path_to_chapter) == 0
def test_state_modification(self, state: BuildState) -> None:
"""Test that state can be modified."""
state.mermaid_counter = 5
state.mermaid_cache["key"] = (b"data", "file.png")
state.mermaid_added_to_book.add("file.png")
state.path_to_chapter["README.md"] = "chap_01.xhtml"
assert state.mermaid_counter == 5
assert state.mermaid_cache["key"] == (b"data", "file.png")
assert "file.png" in state.mermaid_added_to_book
assert state.path_to_chapter["README.md"] == "chap_01.xhtml"
def test_reset(self, state: BuildState) -> None:
"""Test that reset clears all state."""
state.mermaid_counter = 5
state.mermaid_cache["key"] = (b"data", "file.png")
state.mermaid_added_to_book.add("file.png")
state.path_to_chapter["README.md"] = "chap_01.xhtml"
state.reset()
assert state.mermaid_counter == 0
assert len(state.mermaid_cache) == 0
assert len(state.mermaid_added_to_book) == 0
assert len(state.path_to_chapter) == 0
# =============================================================================
# EPUBConfig Tests
# =============================================================================
class TestEPUBConfig:
"""Tests for EPUBConfig dataclass."""
def test_required_fields(self, tmp_path: Path) -> None:
"""Test that required fields must be provided."""
config = EPUBConfig(
root_path=tmp_path,
output_path=tmp_path / "out.epub",
)
assert config.root_path == tmp_path
assert config.output_path == tmp_path / "out.epub"
def test_default_values(self, tmp_path: Path) -> None:
"""Test that default values are set correctly."""
config = EPUBConfig(
root_path=tmp_path,
output_path=tmp_path / "out.epub",
)
assert config.identifier == "claude-howto-guide"
assert config.title == "Claude Code How-To Guide"
assert config.language == "en"
assert config.author == "Claude Code Community"
assert config.request_timeout == 30.0
assert config.max_concurrent_requests == 10
assert config.max_retries == 3
def test_custom_values(self, tmp_path: Path) -> None:
"""Test that custom values override defaults."""
config = EPUBConfig(
root_path=tmp_path,
output_path=tmp_path / "out.epub",
title="Custom Title",
request_timeout=60.0,
max_concurrent_requests=5,
)
assert config.title == "Custom Title"
assert config.request_timeout == 60.0
assert config.max_concurrent_requests == 5
# =============================================================================
# Validation Tests
# =============================================================================
class TestValidation:
"""Tests for input validation."""
def test_valid_inputs(self, config: EPUBConfig, logger: logging.Logger) -> None:
"""Test that valid inputs pass validation."""
# Should not raise
validate_inputs(config, logger)
def test_missing_root_path(self, tmp_path: Path, logger: logging.Logger) -> None:
"""Test that missing root path raises ValidationError."""
config = EPUBConfig(
root_path=tmp_path / "nonexistent",
output_path=tmp_path / "out.epub",
)
with pytest.raises(ValidationError, match="Root path does not exist"):
validate_inputs(config, logger)
def test_root_path_is_file(self, tmp_path: Path, logger: logging.Logger) -> None:
"""Test that file as root path raises ValidationError."""
file_path = tmp_path / "file.txt"
file_path.write_text("content")
config = EPUBConfig(
root_path=file_path,
output_path=tmp_path / "out.epub",
)
with pytest.raises(ValidationError, match="Root path is not a directory"):
validate_inputs(config, logger)
def test_no_markdown_files(self, tmp_path: Path, logger: logging.Logger) -> None:
"""Test that directory with no markdown files raises ValidationError."""
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
config = EPUBConfig(
root_path=empty_dir,
output_path=tmp_path / "out.epub",
)
with pytest.raises(ValidationError, match="No markdown files found"):
validate_inputs(config, logger)
def test_missing_output_directory(
self, tmp_project: Path, logger: logging.Logger
) -> None:
"""Test that missing output directory raises ValidationError."""
config = EPUBConfig(
root_path=tmp_project,
output_path=tmp_project / "nonexistent" / "out.epub",
)
with pytest.raises(ValidationError, match="Output directory does not exist"):
validate_inputs(config, logger)
# =============================================================================
# Mermaid Processing Tests
# =============================================================================
class TestMermaidProcessing:
"""Tests for Mermaid diagram processing."""
def test_sanitize_mermaid_numbered_list(self) -> None:
"""Test that numbered lists in brackets are escaped."""
input_code = 'A["1. First item"] --> B["2. Second item"]'
expected = 'A["1\\. First item"] --> B["2\\. Second item"]'
assert sanitize_mermaid(input_code) == expected
def test_sanitize_mermaid_no_change(self) -> None:
"""Test that code without numbered lists is unchanged."""
input_code = "A --> B --> C"
assert sanitize_mermaid(input_code) == input_code
def test_extract_mermaid_blocks(
self, tmp_path: Path, logger: logging.Logger
) -> None:
"""Test extraction of Mermaid blocks from files."""
# Create test file with mermaid blocks
md_file = tmp_path / "test.md"
md_file.write_text(
"""# Test
```mermaid
graph TD
A --> B
```
Some text
```mermaid
graph LR
C --> D
```
"""
)
diagrams = extract_all_mermaid_blocks([(md_file, "Test")], logger)
assert len(diagrams) == 2
assert diagrams[0][0] == 1 # First diagram index
assert diagrams[1][0] == 2 # Second diagram index
assert "A --> B" in diagrams[0][1]
assert "C --> D" in diagrams[1][1]
def test_extract_mermaid_blocks_deduplication(
self, tmp_path: Path, logger: logging.Logger
) -> None:
"""Test that duplicate Mermaid blocks are deduplicated."""
md_file1 = tmp_path / "test1.md"
md_file2 = tmp_path / "test2.md"
same_diagram = """```mermaid
graph TD
A --> B
```"""
md_file1.write_text(f"# File 1\n\n{same_diagram}")
md_file2.write_text(f"# File 2\n\n{same_diagram}")
diagrams = extract_all_mermaid_blocks(
[(md_file1, "Test1"), (md_file2, "Test2")], logger
)
# Should only have one diagram since they're identical
assert len(diagrams) == 1
# =============================================================================
# Chapter Collection Tests
# =============================================================================
class TestChapterCollector:
"""Tests for ChapterCollector class."""
def test_collect_single_file(self, tmp_path: Path, state: BuildState) -> None:
"""Test collecting a single markdown file."""
readme = tmp_path / "README.md"
readme.write_text("# Test")
collector = ChapterCollector(tmp_path, state)
chapters = collector.collect_all_chapters([("README.md", "Introduction")])
assert len(chapters) == 1
assert chapters[0].file_path == readme
assert chapters[0].display_name == "Introduction"
assert chapters[0].chapter_filename == "chap_01.xhtml"
assert state.path_to_chapter["README.md"] == "chap_01.xhtml"
def test_collect_folder(self, tmp_project: Path, state: BuildState) -> None:
"""Test collecting a folder with multiple files."""
collector = ChapterCollector(tmp_project, state)
chapters = collector.collect_all_chapters([("01-test-chapter", "Test Chapter")])
assert len(chapters) == 2 # README.md and section.md
assert chapters[0].is_folder_overview is True
assert chapters[0].folder_name == "Test Chapter"
assert chapters[1].is_folder_overview is False
def test_path_mapping(self, tmp_project: Path, state: BuildState) -> None:
"""Test that path mapping is built correctly."""
collector = ChapterCollector(tmp_project, state)
collector.collect_all_chapters(
[
("README.md", "Introduction"),
("01-test-chapter", "Test Chapter"),
]
)
assert "README.md" in state.path_to_chapter
assert "01-test-chapter" in state.path_to_chapter
assert "01-test-chapter/README.md" in state.path_to_chapter
# =============================================================================
# HTML Generation Tests
# =============================================================================
class TestHTMLGeneration:
"""Tests for HTML generation."""
def test_create_chapter_html_overview(self) -> None:
"""Test creating HTML for an overview chapter."""
html = create_chapter_html(
display_name="Introduction",
file_title="Introduction",
html_content="<p>Content</p>",
is_overview=True,
)
assert "<!DOCTYPE html>" in html
assert '<html xmlns="http://www.w3.org/1999/xhtml"' in html
assert "<h1>Introduction</h1>" in html
assert "<p>Content</p>" in html
def test_create_chapter_html_section(self) -> None:
"""Test creating HTML for a section chapter."""
html = create_chapter_html(
display_name="Chapter",
file_title="Section",
html_content="<p>Content</p>",
is_overview=False,
)
assert "<h2>Section</h2>" in html
assert "<h1>" not in html
def test_html_escaping(self) -> None:
"""Test that HTML special characters are escaped."""
html = create_chapter_html(
display_name="<script>alert('xss')</script>",
file_title="Test & Title",
html_content="<p>Content</p>",
is_overview=True,
)
assert "&lt;script&gt;" in html
# Note: Python's html.escape uses &#x27; for single quotes
assert "<script>alert" not in html
# =============================================================================
# Chapter Order Tests
# =============================================================================
class TestChapterOrder:
"""Tests for chapter ordering."""
def test_get_chapter_order(self) -> None:
"""Test that chapter order is defined correctly."""
order = get_chapter_order()
assert len(order) > 0
assert order[0] == ("README.md", "Introduction")
# Check that all expected chapters are present
chapter_names = [name for name, _ in order]
assert "01-slash-commands" in chapter_names
assert "02-memory" in chapter_names
assert "resources.md" in chapter_names
# =============================================================================
# Logging Tests
# =============================================================================
class TestLogging:
"""Tests for logging setup."""
def test_setup_logging_default(self) -> None:
"""Test default logging setup."""
logger = setup_logging(verbose=False)
assert logger.name == "epub_builder"
def test_setup_logging_verbose(self) -> None:
"""Test verbose logging setup."""
logger = setup_logging(verbose=True)
assert logger.name == "epub_builder"
# =============================================================================
# Integration Tests
# =============================================================================
class TestIntegration:
"""Integration tests for the full build process."""
@pytest.mark.asyncio
async def test_build_without_mermaid(
self, tmp_project: Path, logger: logging.Logger
) -> None:
"""Test building an EPUB without Mermaid diagrams."""
from build_epub import build_epub_async
config = EPUBConfig(
root_path=tmp_project,
output_path=tmp_project / "test.epub",
)
# Override chapter order for minimal test
with patch("build_epub.get_chapter_order") as mock_order:
mock_order.return_value = [("README.md", "Introduction")]
result = await build_epub_async(config, logger)
assert result.exists()
assert result.suffix == ".epub"
# =============================================================================
# Run tests
# =============================================================================
if __name__ == "__main__":
pytest.main([__file__, "-v"])