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:
106
.github/workflows/ci.yml
vendored
Normal file
106
.github/workflows/ci.yml
vendored
Normal 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
|
||||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -48,4 +48,18 @@ yarn-error.log*
|
|||||||
blog-posts/
|
blog-posts/
|
||||||
|
|
||||||
# EPUB files in root directory
|
# 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
.pre-commit-config.yaml
Normal file
54
.pre-commit-config.yaml
Normal 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 re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def analyze_code_metrics(code):
|
def analyze_code_metrics(code):
|
||||||
"""Analyze code for common metrics."""
|
"""Analyze code for common metrics."""
|
||||||
|
|
||||||
# Count functions
|
# 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
|
# 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
|
# 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
|
avg_length = sum(len(l) for l in lines) / len(lines) if lines else 0
|
||||||
|
|
||||||
# Estimate complexity
|
# 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 {
|
return {
|
||||||
'functions': functions,
|
"functions": functions,
|
||||||
'classes': classes,
|
"classes": classes,
|
||||||
'avg_line_length': avg_length,
|
"avg_line_length": avg_length,
|
||||||
'complexity_score': complexity
|
"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()
|
code = f.read()
|
||||||
metrics = analyze_code_metrics(code)
|
metrics = analyze_code_metrics(code)
|
||||||
for key, value in metrics.items():
|
for key, value in metrics.items():
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ Helps identify if refactoring actually simplifies code structure.
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from typing import Dict, Tuple
|
|
||||||
|
|
||||||
class ComplexityAnalyzer:
|
class ComplexityAnalyzer:
|
||||||
"""Analyze code complexity metrics."""
|
"""Analyze code complexity metrics."""
|
||||||
|
|
||||||
def __init__(self, code: str):
|
def __init__(self, code: str):
|
||||||
self.code = code
|
self.code = code
|
||||||
self.lines = code.split('\n')
|
self.lines = code.split("\n")
|
||||||
|
|
||||||
def calculate_cyclomatic_complexity(self) -> int:
|
def calculate_cyclomatic_complexity(self) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -24,13 +24,13 @@ class ComplexityAnalyzer:
|
|||||||
|
|
||||||
# Count decision points
|
# Count decision points
|
||||||
decision_patterns = [
|
decision_patterns = [
|
||||||
r'\bif\b',
|
r"\bif\b",
|
||||||
r'\belif\b',
|
r"\belif\b",
|
||||||
r'\bfor\b',
|
r"\bfor\b",
|
||||||
r'\bwhile\b',
|
r"\bwhile\b",
|
||||||
r'\bexcept\b',
|
r"\bexcept\b",
|
||||||
r'\band\b(?!$)',
|
r"\band\b(?!$)",
|
||||||
r'\bor\b(?!$)'
|
r"\bor\b(?!$)",
|
||||||
]
|
]
|
||||||
|
|
||||||
for pattern in decision_patterns:
|
for pattern in decision_patterns:
|
||||||
@@ -49,10 +49,10 @@ class ComplexityAnalyzer:
|
|||||||
|
|
||||||
for line in self.lines:
|
for line in self.lines:
|
||||||
# Track nesting depth
|
# 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
|
nesting_depth += 1
|
||||||
cognitive += nesting_depth
|
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
|
cognitive += nesting_depth
|
||||||
|
|
||||||
# Reduce nesting when unindenting
|
# Reduce nesting when unindenting
|
||||||
@@ -74,28 +74,37 @@ class ComplexityAnalyzer:
|
|||||||
cognitive = self.calculate_cognitive_complexity()
|
cognitive = self.calculate_cognitive_complexity()
|
||||||
|
|
||||||
# Simplified MI calculation
|
# 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))
|
return max(0, min(100, mi))
|
||||||
|
|
||||||
def get_complexity_report(self) -> Dict:
|
def get_complexity_report(self) -> dict:
|
||||||
"""Generate comprehensive complexity report."""
|
"""Generate comprehensive complexity report."""
|
||||||
return {
|
return {
|
||||||
'cyclomatic_complexity': self.calculate_cyclomatic_complexity(),
|
"cyclomatic_complexity": self.calculate_cyclomatic_complexity(),
|
||||||
'cognitive_complexity': self.calculate_cognitive_complexity(),
|
"cognitive_complexity": self.calculate_cognitive_complexity(),
|
||||||
'maintainability_index': round(self.calculate_maintainability_index(), 2),
|
"maintainability_index": round(self.calculate_maintainability_index(), 2),
|
||||||
'lines_of_code': len(self.lines),
|
"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
|
"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:
|
def compare_files(before_file: str, after_file: str) -> None:
|
||||||
"""Compare complexity metrics between two code versions."""
|
"""Compare complexity metrics between two code versions."""
|
||||||
|
|
||||||
with open(before_file, 'r') as f:
|
with open(before_file) as f:
|
||||||
before_code = f.read()
|
before_code = f.read()
|
||||||
|
|
||||||
with open(after_file, 'r') as f:
|
with open(after_file) as f:
|
||||||
after_code = f.read()
|
after_code = f.read()
|
||||||
|
|
||||||
before_analyzer = ComplexityAnalyzer(before_code)
|
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(f" Avg Line Length: {after_metrics['avg_line_length']}")
|
||||||
|
|
||||||
print("\nCHANGES:")
|
print("\nCHANGES:")
|
||||||
cyclomatic_change = after_metrics['cyclomatic_complexity'] - before_metrics['cyclomatic_complexity']
|
cyclomatic_change = (
|
||||||
cognitive_change = after_metrics['cognitive_complexity'] - before_metrics['cognitive_complexity']
|
after_metrics["cyclomatic_complexity"] - before_metrics["cyclomatic_complexity"]
|
||||||
mi_change = after_metrics['maintainability_index'] - before_metrics['maintainability_index']
|
)
|
||||||
loc_change = after_metrics['lines_of_code'] - before_metrics['lines_of_code']
|
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" Cyclomatic Complexity: {cyclomatic_change:+d}")
|
||||||
print(f" Cognitive Complexity: {cognitive_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)
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
if len(sys.argv) != 3:
|
if len(sys.argv) != 3:
|
||||||
print("Usage: python compare-complexity.py <before_file> <after_file>")
|
print("Usage: python compare-complexity.py <before_file> <after_file>")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import ast
|
import ast
|
||||||
import json
|
|
||||||
from typing import Dict, List
|
|
||||||
|
|
||||||
class APIDocExtractor(ast.NodeVisitor):
|
class APIDocExtractor(ast.NodeVisitor):
|
||||||
"""Extract API documentation from Python source code."""
|
"""Extract API documentation from Python source code."""
|
||||||
@@ -11,13 +10,13 @@ class APIDocExtractor(ast.NodeVisitor):
|
|||||||
|
|
||||||
def visit_FunctionDef(self, node):
|
def visit_FunctionDef(self, node):
|
||||||
"""Extract function documentation."""
|
"""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)
|
doc = ast.get_docstring(node)
|
||||||
endpoint = {
|
endpoint = {
|
||||||
'name': node.name,
|
"name": node.name,
|
||||||
'docstring': doc,
|
"docstring": doc,
|
||||||
'params': [arg.arg for arg in node.args.args],
|
"params": [arg.arg for arg in node.args.args],
|
||||||
'returns': self._extract_return_type(node)
|
"returns": self._extract_return_type(node),
|
||||||
}
|
}
|
||||||
self.endpoints.append(endpoint)
|
self.endpoints.append(endpoint)
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
@@ -28,7 +27,8 @@ class APIDocExtractor(ast.NodeVisitor):
|
|||||||
return ast.unparse(node.returns)
|
return ast.unparse(node.returns)
|
||||||
return "Any"
|
return "Any"
|
||||||
|
|
||||||
def generate_markdown_docs(endpoints: List[Dict]) -> str:
|
|
||||||
|
def generate_markdown_docs(endpoints: list[dict]) -> str:
|
||||||
"""Generate markdown documentation from endpoints."""
|
"""Generate markdown documentation from endpoints."""
|
||||||
docs = "# API Documentation\n\n"
|
docs = "# API Documentation\n\n"
|
||||||
|
|
||||||
@@ -41,9 +41,11 @@ def generate_markdown_docs(endpoints: List[Dict]) -> str:
|
|||||||
|
|
||||||
return docs
|
return docs
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
with open(sys.argv[1], 'r') as f:
|
|
||||||
|
with open(sys.argv[1]) as f:
|
||||||
tree = ast.parse(f.read())
|
tree = ast.parse(f.read())
|
||||||
|
|
||||||
extractor = APIDocExtractor()
|
extractor = APIDocExtractor()
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
# Claude How To
|
# 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.
|
Complete collection of examples for some important Claude Code features and concepts.
|
||||||
|
|
||||||
## Quick Navigation
|
## 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+
|
**Claude Code Version**: 1.0+
|
||||||
**Compatible Models**: Sonnet 4.5, Opus 4.1, Haiku 4.5
|
**Compatible Models**: Sonnet 4.5, Opus 4.1, Haiku 4.5
|
||||||
|
|||||||
100
pyproject.toml
Normal file
100
pyproject.toml
Normal 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
requirements-dev.txt
Normal file
11
requirements-dev.txt
Normal 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
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Core dependencies for build_epub.py
|
||||||
|
ebooklib
|
||||||
|
markdown
|
||||||
|
beautifulsoup4
|
||||||
|
httpx
|
||||||
|
pillow
|
||||||
|
tenacity
|
||||||
115
scripts/README.md
Normal file
115
scripts/README.md
Normal 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.
|
||||||
File diff suppressed because it is too large
Load Diff
1
scripts/tests/__init__.py
Normal file
1
scripts/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Tests for build_epub module
|
||||||
58
scripts/tests/conftest.py
Normal file
58
scripts/tests/conftest.py
Normal 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
scripts/tests/test_build_epub.py
Normal file
414
scripts/tests/test_build_epub.py
Normal 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 "<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