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,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
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
# Core dependencies for build_epub.py
|
||||
ebooklib
|
||||
markdown
|
||||
beautifulsoup4
|
||||
httpx
|
||||
pillow
|
||||
tenacity
|
||||
@@ -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