mirror of
https://github.com/luongnv89/claude-howto.git
synced 2026-06-05 22:36:34 +02:00
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:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
# Tests for build_epub module
|
||||
@@ -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)
|
||||
@@ -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 "<script>" in html
|
||||
# Note: Python's html.escape uses ' 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"])
|
||||
Reference in New Issue
Block a user