diff --git a/README.md b/README.md index 602d15a..b3fdb4e 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,15 @@ [![PyPI version](https://badge.fury.io/py/article-cli.svg)](https://badge.fury.io/py/article-cli) [![Python Support](https://img.shields.io/pypi/pyversions/article-cli.svg)](https://pypi.org/project/article-cli/) -A command-line tool for managing LaTeX articles with git integration and Zotero bibliography synchronization. +A command-line tool for managing LaTeX articles and presentations with git integration and Zotero bibliography synchronization. ## Features -- **Repository Initialization**: Complete setup for LaTeX article projects with one command -- **LaTeX Compilation**: Compile documents with latexmk/pdflatex, watch mode, shell escape support -- **GitHub Actions Workflows**: Automated PDF compilation, artifact upload, and GitHub releases +- **Repository Initialization**: Complete setup for LaTeX article or presentation projects with one command +- **Project Types**: Support for articles, Beamer presentations, and posters +- **LaTeX Compilation**: Compile documents with latexmk/pdflatex/xelatex/lualatex, watch mode, shell escape support +- **Font Installation**: Download and install fonts for XeLaTeX projects (Marianne, Roboto Mono, etc.) +- **GitHub Actions Workflows**: Automated PDF compilation with XeLaTeX support, artifact upload, and GitHub releases - **Git Release Management**: Create, list, and delete releases with gitinfo2 support - **Zotero Integration**: Synchronize bibliography from Zotero with robust pagination and error handling - **LaTeX Build Management**: Clean build files and manage LaTeX compilation artifacts @@ -113,6 +115,24 @@ default_branch = "main" [latex] clean_extensions = [".aux", ".bbl", ".blg", ".log", ".out", ".synctex.gz"] + +[fonts] +directory = "fonts" + +[fonts.sources] +marianne = "https://github.com/ArnaudBelcworking/Marianne/archive/refs/heads/master.zip" +roboto-mono = "https://github.com/googlefonts/RobotoMono/releases/download/v3.000/RobotoMono-v3.000.zip" + +[themes] +directory = "." + +# Custom theme sources (numpex is built-in) +# [themes.sources.my-theme] +# url = "https://example.com/theme.zip" +# description = "My custom theme" +# files = ["beamerthememytheme.sty"] +# requires_fonts = false +# engine = "pdflatex" ``` ## Usage @@ -123,6 +143,12 @@ clean_extensions = [".aux", ".bbl", ".blg", ".log", ".out", ".synctex.gz"] # Initialize a new article repository (auto-detects main .tex file) article-cli init --title "My Article Title" --authors "John Doe,Jane Smith" +# Initialize a Beamer presentation project +article-cli init --title "My Presentation" --authors "Author" --type presentation + +# Initialize with numpex theme (requires theme files from presentation.template.d) +article-cli init --title "NumPEx Talk" --authors "Author" --type presentation --theme numpex + # Specify custom Zotero group ID article-cli init --title "My Article" --authors "Author" --group-id 1234567 @@ -134,11 +160,12 @@ article-cli init --title "My Article" --authors "Author" --force ``` The `init` command sets up: -- **GitHub Actions workflow** for automated PDF compilation and releases +- **GitHub Actions workflow** for automated PDF compilation and releases (with XeLaTeX support for presentations) - **pyproject.toml** with dependencies and article-cli configuration - **README.md** with comprehensive documentation - **.gitignore** with LaTeX-specific patterns - **VS Code configuration** for LaTeX Workshop with auto-build and SyncTeX +- **Font configuration** (for presentation projects using custom themes) ### Git Release Management @@ -178,6 +205,12 @@ article-cli compile main.tex # Compile with pdflatex engine article-cli compile --engine pdflatex +# Compile with XeLaTeX (for custom fonts) +article-cli compile --engine xelatex + +# Compile with LuaLaTeX +article-cli compile --engine lualatex + # Enable shell escape (for code highlighting, etc.) article-cli compile --shell-escape @@ -191,6 +224,64 @@ article-cli compile --clean-first --clean-after article-cli compile --output-dir build/ ``` +### Font Installation + +Install fonts for XeLaTeX projects (useful for custom Beamer themes): + +```bash +# Install default fonts (Marianne, Roboto Mono) to fonts/ directory +article-cli install-fonts + +# Install to a custom directory +article-cli install-fonts --dir custom-fonts/ + +# Force re-download even if fonts exist +article-cli install-fonts --force + +# List installed fonts +article-cli install-fonts --list +``` + +**Default fonts:** +- **Marianne**: French government official font +- **Roboto Mono**: Google's monospace font for code + +### Theme Installation + +Install Beamer themes for presentations: + +```bash +# List available themes +article-cli install-theme --list + +# Install numpex theme (NumPEx Beamer theme) +article-cli install-theme numpex + +# Install to a custom directory +article-cli install-theme numpex --dir themes/ + +# Force re-download even if theme exists +article-cli install-theme numpex --force + +# Install from a custom URL +article-cli install-theme my-theme --url https://example.com/theme.zip +``` + +**Available themes:** +- **numpex**: NumPEx Beamer theme following French government visual identity (requires XeLaTeX and custom fonts) + +**Complete presentation setup:** +```bash +# 1. Install the theme +article-cli install-theme numpex + +# 2. Install required fonts +article-cli install-fonts + +# 3. Compile with XeLaTeX +article-cli compile presentation.tex --engine xelatex +``` + ### Project Setup ```bash @@ -237,6 +328,22 @@ MIT License - see LICENSE file for details. ## Changelog +### v1.2.0 +- Add font installation command (`install-fonts`) for XeLaTeX projects +- Support Marianne and Roboto Mono fonts by default +- Add theme installation command (`install-theme`) for Beamer presentations +- Built-in support for numpex theme with automatic download +- Extended GitHub Actions workflow with XeLaTeX and multi-document support +- Add presentation project type with Beamer template support +- Add `--engine` option for xelatex and lualatex compilation +- Improved CI/CD with font installation steps + +### v1.1.0 +- Add `init` command for repository initialization +- Add `compile` command with watch mode and multiple engines +- GitHub Actions workflow generation +- VS Code configuration generation + ### v1.0.0 - Initial release - Git release management diff --git a/src/article_cli/cli.py b/src/article_cli/cli.py index 9cfe6e9..11cabf6 100644 --- a/src/article_cli/cli.py +++ b/src/article_cli/cli.py @@ -38,6 +38,8 @@ def create_parser() -> argparse.ArgumentParser: %(prog)s config create # Create sample config file %(prog)s install-fonts # Install fonts for XeLaTeX %(prog)s install-fonts --list # List installed fonts + %(prog)s install-theme numpex # Install numpex Beamer theme + %(prog)s install-theme --list # List available themes Environment variables: ZOTERO_API_KEY : Your Zotero API key (required for update-bibtex) @@ -207,6 +209,36 @@ def create_parser() -> argparse.ArgumentParser: help="List installed fonts instead of installing", ) + # Install-theme command + theme_parser = subparsers.add_parser( + "install-theme", help="Download and install Beamer themes for presentations" + ) + theme_parser.add_argument( + "theme_name", + nargs="?", + help="Name of theme to install (e.g., 'numpex')", + ) + theme_parser.add_argument( + "--dir", + type=Path, + help="Directory to install theme (default: current directory)", + ) + theme_parser.add_argument( + "--force", + action="store_true", + help="Re-download theme even if already installed", + ) + theme_parser.add_argument( + "--list", + action="store_true", + dest="list_themes", + help="List available themes instead of installing", + ) + theme_parser.add_argument( + "--url", + help="Custom URL to download theme from (use with theme_name)", + ) + return parser @@ -496,6 +528,68 @@ def handle_install_fonts_command(args: argparse.Namespace, config: Config) -> in return 1 +def handle_install_theme_command(args: argparse.Namespace, config: Config) -> int: + """Handle the install-theme command""" + try: + from .themes import ThemeInstaller + + themes_config = config.get_themes_config() + + # Override directory if specified on command line + themes_dir = args.dir if args.dir else Path(themes_config.get("directory", ".")) + + # Get configured sources + sources = themes_config.get("sources", {}) + + installer = ThemeInstaller(themes_dir=themes_dir, sources=sources) + + # List available themes if requested + if args.list_themes: + available = installer.list_available() + installed = installer.list_installed() + + print_info("Available themes:") + for theme in available: + name = theme["name"] + desc = theme.get("description", "") + engine = theme.get("engine", "pdflatex") + fonts = ( + " (requires custom fonts)" if theme.get("requires_fonts") else "" + ) + installed_marker = ( + " [installed]" if any(i["name"] == name for i in installed) else "" + ) + print(f" - {name}: {desc}") + print(f" Engine: {engine}{fonts}{installed_marker}") + + return 0 + + # Theme name is required for installation + if not args.theme_name: + print_error("Theme name is required. Use --list to see available themes.") + return 1 + + # Install from custom URL if provided + if args.url: + success = installer.install_from_url( + name=args.theme_name, + url=args.url, + force=args.force, + ) + else: + # Install from known sources + success = installer.install_theme( + name=args.theme_name, + force=args.force, + ) + + return 0 if success else 1 + + except Exception as e: + print_error(f"Theme installation failed: {e}") + return 1 + + def main(argv: Optional[list] = None) -> int: """ Main entry point for article-cli @@ -552,6 +646,9 @@ def main(argv: Optional[list] = None) -> int: elif args.command == "install-fonts": return handle_install_fonts_command(args, config) + elif args.command == "install-theme": + return handle_install_theme_command(args, config) + else: print_error(f"Unknown command: {args.command}") return 1 diff --git a/src/article_cli/config.py b/src/article_cli/config.py index ce37ea7..2033e96 100644 --- a/src/article_cli/config.py +++ b/src/article_cli/config.py @@ -233,6 +233,29 @@ def get_fonts_config(self) -> Dict[str, Any]: "sources": self.get("fonts", "sources", default_sources), } + def get_themes_config(self) -> Dict[str, Any]: + """Get theme installation configuration""" + # Default theme sources + default_sources = { + "numpex": { + "url": "https://github.com/numpex/presentation.template.d/archive/refs/heads/main.zip", + "description": "NumPEx Beamer theme following French government visual identity", + "files": [ + "beamerthemenumpex.sty", + "beamercolorthemenumpex.sty", + "beamerfontthemenumpex.sty", + ], + "directories": ["images"], + "requires_fonts": True, + "engine": "xelatex", + }, + } + + return { + "directory": self.get("themes", "directory", "."), + "sources": self.get("themes", "sources", default_sources), + } + def validate_zotero_config( self, args: argparse.Namespace ) -> Dict[str, Optional[str]]: @@ -399,6 +422,20 @@ def create_sample_config(self, path: Optional[Path] = None) -> Path: # name = "Roboto Mono" # url = "https://fonts.google.com/download?family=Roboto+Mono" # description = "Google's monospace font" + +# Theme installation settings (for Beamer presentations) +[themes] +# Directory to install themes (default: current directory) +directory = "." + +# Theme sources (default: numpex theme) +# [themes.sources.numpex] +# url = "https://github.com/numpex/presentation.template.d/archive/refs/heads/main.zip" +# description = "NumPEx Beamer theme" +# files = ["beamerthemenumpex.sty", "beamercolorthemenumpex.sty", "beamerfontthemenumpex.sty"] +# directories = ["images"] +# requires_fonts = true +# engine = "xelatex" """ try: diff --git a/src/article_cli/themes.py b/src/article_cli/themes.py new file mode 100644 index 0000000..179bc2a --- /dev/null +++ b/src/article_cli/themes.py @@ -0,0 +1,383 @@ +""" +Theme installation module for article-cli + +Provides functionality to download and install Beamer themes for presentations. +""" + +import os +import zipfile +import tempfile +import shutil +from pathlib import Path +from typing import List, Dict, Optional, Any +from urllib.request import urlopen, Request +from urllib.error import URLError, HTTPError + +from .zotero import print_error, print_info, print_success, print_warning + + +# Default theme sources +DEFAULT_THEME_SOURCES: Dict[str, Dict[str, Any]] = { + "numpex": { + "url": "https://github.com/numpex/presentation.template.d/archive/refs/heads/main.zip", + "description": "NumPEx Beamer theme following French government visual identity", + "files": [ + "beamerthemenumpex.sty", + "beamercolorthemenumpex.sty", + "beamerfontthemenumpex.sty", + ], + "directories": ["images"], + "requires_fonts": True, + "engine": "xelatex", + }, +} + + +class ThemeInstaller: + """Handles downloading and installing Beamer themes for presentations""" + + def __init__( + self, + themes_dir: Optional[Path] = None, + sources: Optional[Dict[str, Dict[str, Any]]] = None, + ): + """ + Initialize theme installer + + Args: + themes_dir: Directory to install themes (default: current directory) + sources: Dict of theme sources with name as key + """ + self.themes_dir = themes_dir or Path(".") + self.sources = sources or DEFAULT_THEME_SOURCES + + def list_available(self) -> List[Dict[str, Any]]: + """ + List all available themes + + Returns: + List of theme info dicts + """ + themes = [] + for name, info in self.sources.items(): + themes.append( + { + "name": name, + "description": info.get("description", ""), + "url": info.get("url", ""), + "requires_fonts": info.get("requires_fonts", False), + "engine": info.get("engine", "pdflatex"), + } + ) + return themes + + def list_installed(self) -> List[Dict[str, Any]]: + """ + List installed themes in the themes directory + + Returns: + List of installed theme info + """ + installed = [] + + for name, info in self.sources.items(): + # Check if theme files exist + theme_files = info.get("files", []) + if theme_files: + main_file = self.themes_dir / theme_files[0] + if main_file.exists(): + installed.append( + { + "name": name, + "path": str(self.themes_dir), + "files": [ + str(self.themes_dir / f) + for f in theme_files + if (self.themes_dir / f).exists() + ], + } + ) + + return installed + + def install_theme(self, name: str, force: bool = False) -> bool: + """ + Install a theme by name + + Args: + name: Theme name (must be in sources) + force: Re-download even if theme already exists + + Returns: + True if theme installed successfully + """ + if name not in self.sources: + print_error(f"Unknown theme: '{name}'") + print_info("Available themes:") + for theme_name, theme_info in self.sources.items(): + desc = theme_info.get("description", "") + print_info(f" - {theme_name}: {desc}") + return False + + theme_info = self.sources[name] + theme_files = theme_info.get("files", []) + theme_dirs = theme_info.get("directories", []) + + # Check if already installed + if theme_files and not force: + main_file = self.themes_dir / theme_files[0] + if main_file.exists(): + print_info(f"Theme '{name}' already installed at {self.themes_dir}") + return True + + url = theme_info.get("url", "") + if not url: + print_error(f"No URL configured for theme '{name}'") + return False + + description = theme_info.get("description", "") + print_info(f"Installing theme '{name}'...") + if description: + print_info(f" {description}") + + try: + self._download_and_extract_theme(name, url, theme_files, theme_dirs) + print_success(f"Theme '{name}' installed successfully") + + # Show additional info + if theme_info.get("requires_fonts"): + print_info("") + print_warning("This theme requires custom fonts.") + print_info("Run 'article-cli install-fonts' to install them.") + + engine = theme_info.get("engine", "pdflatex") + if engine != "pdflatex": + print_info("") + print_info(f"This theme requires {engine} for compilation.") + print_info(f"Use: article-cli compile --engine {engine}") + + self._print_usage_instructions(name) + return True + + except Exception as e: + print_error(f"Failed to install theme '{name}': {e}") + return False + + def install_from_url( + self, + name: str, + url: str, + files: Optional[List[str]] = None, + force: bool = False, + ) -> bool: + """ + Install a theme from a custom URL + + Args: + name: Theme name to use + url: URL to download theme archive + files: List of specific files to extract (optional) + force: Re-download even if theme exists + + Returns: + True if theme installed successfully + """ + print_info(f"Installing theme '{name}' from {url}...") + + try: + self._download_and_extract_theme(name, url, files or [], []) + print_success(f"Theme '{name}' installed successfully") + return True + except Exception as e: + print_error(f"Failed to install theme '{name}': {e}") + return False + + def _download_and_extract_theme( + self, + name: str, + url: str, + theme_files: List[str], + theme_dirs: List[str], + ) -> None: + """ + Download and extract theme files from archive + + Args: + name: Theme name for display + url: URL to download + theme_files: Specific files to extract + theme_dirs: Specific directories to extract + """ + # Create themes directory + self.themes_dir.mkdir(parents=True, exist_ok=True) + + # Download to temporary file + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp_file: + tmp_path = Path(tmp_file.name) + + try: + print_info(" Downloading...") + self._download_file(url, tmp_path) + + print_info(f" Extracting theme files...") + self._extract_theme_files(tmp_path, theme_files, theme_dirs) + + finally: + # Clean up temporary file + if tmp_path.exists(): + tmp_path.unlink() + + def _download_file(self, url: str, dest: Path) -> None: + """ + Download a file from URL with progress indication + + Args: + url: URL to download + dest: Destination path + """ + # Create request with user agent to avoid blocks + headers = { + "User-Agent": "Mozilla/5.0 (compatible; article-cli theme installer)" + } + request = Request(url, headers=headers) + + try: + with urlopen(request, timeout=60) as response: + # Get file size if available + content_length = response.headers.get("Content-Length") + total_size = int(content_length) if content_length else None + + # Download in chunks + chunk_size = 8192 + downloaded = 0 + + with open(dest, "wb") as f: + while True: + chunk = response.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + + # Show progress + if total_size: + percent = (downloaded / total_size) * 100 + print( + f"\r Progress: {downloaded:,} / {total_size:,} bytes ({percent:.1f}%)", + end="", + flush=True, + ) + else: + print( + f"\r Downloaded: {downloaded:,} bytes", + end="", + flush=True, + ) + + print() # New line after progress + + except HTTPError as e: + raise RuntimeError(f"HTTP error {e.code}: {e.reason}") + except URLError as e: + raise RuntimeError(f"URL error: {e.reason}") + except TimeoutError: + raise RuntimeError("Download timed out") + + def _extract_theme_files( + self, zip_path: Path, theme_files: List[str], theme_dirs: List[str] + ) -> None: + """ + Extract specific theme files from a zip archive + + Args: + zip_path: Path to zip file + theme_files: List of file names to extract + theme_dirs: List of directory names to extract + """ + try: + with zipfile.ZipFile(zip_path, "r") as zf: + # Get all file names in archive + all_files = zf.namelist() + + # Find and extract theme files + extracted_count = 0 + + for member in all_files: + member_name = Path(member).name + member_parts = Path(member).parts + + # Check if this is one of the theme files + if theme_files and member_name in theme_files: + # Extract to themes directory with just the filename + target_path = self.themes_dir / member_name + with zf.open(member) as source: + with open(target_path, "wb") as target: + target.write(source.read()) + print_info(f" Extracted: {member_name}") + extracted_count += 1 + + # Check if this is in one of the theme directories + for theme_dir in theme_dirs: + if theme_dir in member_parts: + # Find the index of the theme_dir in the path + dir_idx = member_parts.index(theme_dir) + # Build relative path from theme_dir onwards + rel_parts = member_parts[dir_idx:] + rel_path = Path(*rel_parts) + + target_path = self.themes_dir / rel_path + + if member.endswith("/"): + # It's a directory + target_path.mkdir(parents=True, exist_ok=True) + else: + # It's a file + target_path.parent.mkdir(parents=True, exist_ok=True) + with zf.open(member) as source: + with open(target_path, "wb") as target: + target.write(source.read()) + print_info(f" Extracted: {rel_path}") + extracted_count += 1 + break + + # If no specific files requested, extract all .sty files + if not theme_files and not theme_dirs: + for member in all_files: + if member.endswith(".sty"): + member_name = Path(member).name + target_path = self.themes_dir / member_name + with zf.open(member) as source: + with open(target_path, "wb") as target: + target.write(source.read()) + print_info(f" Extracted: {member_name}") + extracted_count += 1 + + print_info(f" Extracted {extracted_count} files") + + except zipfile.BadZipFile: + raise RuntimeError("Invalid or corrupted zip file") + + def _print_usage_instructions(self, name: str) -> None: + """Print instructions for using installed theme""" + print_info("") + print_info("To use this theme in your presentation:") + print_info(f" \\usetheme{{{name}}}") + print_info("") + print_info("Example document:") + print_info(" \\documentclass[aspectratio=169]{beamer}") + print_info(f" \\usetheme{{{name}}}") + print_info(" \\title{Your Title}") + print_info(" \\author{Your Name}") + print_info(" \\begin{document}") + print_info(" \\maketitle") + print_info(" \\end{document}") + + +def get_available_themes() -> Dict[str, Dict[str, Any]]: + """Get dictionary of available themes""" + return DEFAULT_THEME_SOURCES.copy() + + +def get_theme_info(name: str) -> Optional[Dict[str, Any]]: + """Get info for a specific theme""" + return DEFAULT_THEME_SOURCES.get(name) diff --git a/tests/__init__.py b/tests/__init__.py index 1bd2438..415bdad 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Empty test init file""" \ No newline at end of file +"""Empty test init file""" diff --git a/tests/test_config.py b/tests/test_config.py index e7b4285..183c911 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -23,74 +23,71 @@ def test_init_without_config_file(self): def test_get_default_values(self): """Test getting configuration with default values""" config = Config() - + # Test with no config or env vars - value = config.get('section', 'key', 'default_value') - assert value == 'default_value' + value = config.get("section", "key", "default_value") + assert value == "default_value" - @patch.dict(os.environ, {'TEST_ENV_VAR': 'env_value'}) + @patch.dict(os.environ, {"TEST_ENV_VAR": "env_value"}) def test_get_environment_variable_priority(self): """Test that environment variables take priority""" config = Config() - - value = config.get('section', 'key', 'default_value', env_var='TEST_ENV_VAR') - assert value == 'env_value' + + value = config.get("section", "key", "default_value", env_var="TEST_ENV_VAR") + assert value == "env_value" def test_get_zotero_config_defaults(self): """Test getting Zotero configuration with defaults""" config = Config() zotero_config = config.get_zotero_config() - - assert zotero_config['api_key'] is None - assert zotero_config['user_id'] is None - assert zotero_config['group_id'] is None - assert zotero_config['output_file'] == 'references.bib' - - @patch.dict(os.environ, { - 'ZOTERO_API_KEY': 'test_key', - 'ZOTERO_GROUP_ID': '12345' - }) + + assert zotero_config["api_key"] is None + assert zotero_config["user_id"] is None + assert zotero_config["group_id"] is None + assert zotero_config["output_file"] == "references.bib" + + @patch.dict(os.environ, {"ZOTERO_API_KEY": "test_key", "ZOTERO_GROUP_ID": "12345"}) def test_get_zotero_config_from_env(self): """Test getting Zotero configuration from environment""" config = Config() zotero_config = config.get_zotero_config() - - assert zotero_config['api_key'] == 'test_key' - assert zotero_config['group_id'] == '12345' + + assert zotero_config["api_key"] == "test_key" + assert zotero_config["group_id"] == "12345" def test_get_git_config_defaults(self): """Test getting Git configuration with defaults""" config = Config() git_config = config.get_git_config() - - assert git_config['auto_push'] is False - assert git_config['default_branch'] == 'main' + + assert git_config["auto_push"] is False + assert git_config["default_branch"] == "main" def test_get_latex_config_defaults(self): """Test getting LaTeX configuration with defaults""" config = Config() latex_config = config.get_latex_config() - - assert isinstance(latex_config['clean_extensions'], list) - assert '.aux' in latex_config['clean_extensions'] - assert latex_config['build_dir'] == '.' + + assert isinstance(latex_config["clean_extensions"], list) + assert ".aux" in latex_config["clean_extensions"] + assert latex_config["build_dir"] == "." def test_create_sample_config(self): """Test creating sample configuration file""" config = Config() - + with tempfile.TemporaryDirectory() as temp_dir: - config_path = Path(temp_dir) / 'test-config.toml' + config_path = Path(temp_dir) / "test-config.toml" result_path = config.create_sample_config(config_path) - + assert result_path == config_path assert config_path.exists() - + # Check that the file contains expected sections content = config_path.read_text() - assert '[zotero]' in content - assert '[git]' in content - assert '[latex]' in content + assert "[zotero]" in content + assert "[git]" in content + assert "[latex]" in content # Mock argparse.Namespace for testing @@ -107,58 +104,62 @@ def test_validate_missing_api_key(self): """Test validation fails when API key is missing""" config = Config() args = MockArgs() - + with pytest.raises(ValueError, match="Zotero API key is required"): config.validate_zotero_config(args) def test_validate_missing_ids(self): """Test validation fails when both user and group IDs are missing""" config = Config() - args = MockArgs(api_key='test_key') - - with pytest.raises(ValueError, match="Either Zotero user ID or group ID is required"): + args = MockArgs(api_key="test_key") + + with pytest.raises( + ValueError, match="Either Zotero user ID or group ID is required" + ): config.validate_zotero_config(args) def test_validate_success_with_group_id(self): """Test successful validation with group ID""" config = Config() - args = MockArgs(api_key='test_key', group_id='12345') - + args = MockArgs(api_key="test_key", group_id="12345") + result = config.validate_zotero_config(args) - - assert result['api_key'] == 'test_key' - assert result['group_id'] == '12345' - assert result['user_id'] is None + + assert result["api_key"] == "test_key" + assert result["group_id"] == "12345" + assert result["user_id"] is None def test_validate_success_with_user_id(self): """Test successful validation with user ID""" config = Config() - args = MockArgs(api_key='test_key', user_id='67890') - + args = MockArgs(api_key="test_key", user_id="67890") + result = config.validate_zotero_config(args) - - assert result['api_key'] == 'test_key' - assert result['user_id'] == '67890' - assert result['group_id'] is None - @patch.dict(os.environ, {'ZOTERO_API_KEY': 'env_key', 'ZOTERO_GROUP_ID': 'env_group'}) + assert result["api_key"] == "test_key" + assert result["user_id"] == "67890" + assert result["group_id"] is None + + @patch.dict( + os.environ, {"ZOTERO_API_KEY": "env_key", "ZOTERO_GROUP_ID": "env_group"} + ) def test_validate_with_env_vars(self): """Test validation uses environment variables""" config = Config() args = MockArgs() - + result = config.validate_zotero_config(args) - - assert result['api_key'] == 'env_key' - assert result['group_id'] == 'env_group' + + assert result["api_key"] == "env_key" + assert result["group_id"] == "env_group" def test_validate_args_override_env(self): """Test that command line args override environment variables""" - with patch.dict(os.environ, {'ZOTERO_API_KEY': 'env_key'}): + with patch.dict(os.environ, {"ZOTERO_API_KEY": "env_key"}): config = Config() - args = MockArgs(api_key='arg_key', group_id='arg_group') - + args = MockArgs(api_key="arg_key", group_id="arg_group") + result = config.validate_zotero_config(args) - - assert result['api_key'] == 'arg_key' - assert result['group_id'] == 'arg_group' \ No newline at end of file + + assert result["api_key"] == "arg_key" + assert result["group_id"] == "arg_group" diff --git a/tests/test_latex_compiler.py b/tests/test_latex_compiler.py index 9e965e5..e6d466a 100644 --- a/tests/test_latex_compiler.py +++ b/tests/test_latex_compiler.py @@ -26,15 +26,19 @@ def compiler(self): def mock_tex_path(self, tmp_path): """Create a mock .tex file""" tex_file = tmp_path / "test.tex" - tex_file.write_text(r"\documentclass{article}\begin{document}Hello\end{document}") + tex_file.write_text( + r"\documentclass{article}\begin{document}Hello\end{document}" + ) return tex_file # --- Test _build_latexmk_command with different pdf_modes --- def test_build_latexmk_command_default_pdf(self, compiler, mock_tex_path): """Test latexmk command builds with -pdf flag by default""" - cmd = compiler._build_latexmk_command(mock_tex_path, shell_escape=False, output_dir=None) - + cmd = compiler._build_latexmk_command( + mock_tex_path, shell_escape=False, output_dir=None + ) + assert "latexmk" in cmd assert "-pdf" in cmd assert "-xelatex" not in cmd @@ -45,7 +49,7 @@ def test_build_latexmk_command_xelatex_mode(self, compiler, mock_tex_path): cmd = compiler._build_latexmk_command( mock_tex_path, shell_escape=False, output_dir=None, pdf_mode="xelatex" ) - + assert "latexmk" in cmd assert "-xelatex" in cmd assert "-pdf" not in cmd @@ -56,7 +60,7 @@ def test_build_latexmk_command_lualatex_mode(self, compiler, mock_tex_path): cmd = compiler._build_latexmk_command( mock_tex_path, shell_escape=False, output_dir=None, pdf_mode="lualatex" ) - + assert "latexmk" in cmd assert "-lualatex" in cmd assert "-pdf" not in cmd @@ -64,8 +68,10 @@ def test_build_latexmk_command_lualatex_mode(self, compiler, mock_tex_path): def test_build_latexmk_command_with_shell_escape(self, compiler, mock_tex_path): """Test latexmk command includes --shell-escape when requested""" - cmd = compiler._build_latexmk_command(mock_tex_path, shell_escape=True, output_dir=None) - + cmd = compiler._build_latexmk_command( + mock_tex_path, shell_escape=True, output_dir=None + ) + assert "--shell-escape" in cmd def test_build_latexmk_command_with_output_dir(self, compiler, mock_tex_path): @@ -73,7 +79,7 @@ def test_build_latexmk_command_with_output_dir(self, compiler, mock_tex_path): cmd = compiler._build_latexmk_command( mock_tex_path, shell_escape=False, output_dir="build" ) - + assert "-outdir" in cmd assert "build" in cmd @@ -82,15 +88,17 @@ def test_build_latexmk_command_continuous_mode(self, compiler, mock_tex_path): cmd = compiler._build_latexmk_command( mock_tex_path, shell_escape=False, output_dir=None, continuous=True ) - + assert "-pvc" in cmd # --- Test _build_xelatex_command --- def test_build_xelatex_command_basic(self, compiler, mock_tex_path): """Test xelatex command builds correctly""" - cmd = compiler._build_xelatex_command(mock_tex_path, shell_escape=False, output_dir=None) - + cmd = compiler._build_xelatex_command( + mock_tex_path, shell_escape=False, output_dir=None + ) + assert cmd[0] == "xelatex" assert "-synctex=1" in cmd assert "-interaction=nonstopmode" in cmd @@ -99,14 +107,18 @@ def test_build_xelatex_command_basic(self, compiler, mock_tex_path): def test_build_xelatex_command_with_shell_escape(self, compiler, mock_tex_path): """Test xelatex command includes --shell-escape""" - cmd = compiler._build_xelatex_command(mock_tex_path, shell_escape=True, output_dir=None) - + cmd = compiler._build_xelatex_command( + mock_tex_path, shell_escape=True, output_dir=None + ) + assert "--shell-escape" in cmd def test_build_xelatex_command_with_output_dir(self, compiler, mock_tex_path): """Test xelatex command includes output directory""" - cmd = compiler._build_xelatex_command(mock_tex_path, shell_escape=False, output_dir="build") - + cmd = compiler._build_xelatex_command( + mock_tex_path, shell_escape=False, output_dir="build" + ) + assert "-output-directory" in cmd assert "build" in cmd @@ -114,8 +126,10 @@ def test_build_xelatex_command_with_output_dir(self, compiler, mock_tex_path): def test_build_lualatex_command_basic(self, compiler, mock_tex_path): """Test lualatex command builds correctly""" - cmd = compiler._build_lualatex_command(mock_tex_path, shell_escape=False, output_dir=None) - + cmd = compiler._build_lualatex_command( + mock_tex_path, shell_escape=False, output_dir=None + ) + assert cmd[0] == "lualatex" assert "-synctex=1" in cmd assert "-interaction=nonstopmode" in cmd @@ -124,14 +138,18 @@ def test_build_lualatex_command_basic(self, compiler, mock_tex_path): def test_build_lualatex_command_with_shell_escape(self, compiler, mock_tex_path): """Test lualatex command includes --shell-escape""" - cmd = compiler._build_lualatex_command(mock_tex_path, shell_escape=True, output_dir=None) - + cmd = compiler._build_lualatex_command( + mock_tex_path, shell_escape=True, output_dir=None + ) + assert "--shell-escape" in cmd def test_build_lualatex_command_with_output_dir(self, compiler, mock_tex_path): """Test lualatex command includes output directory""" - cmd = compiler._build_lualatex_command(mock_tex_path, shell_escape=False, output_dir="build") - + cmd = compiler._build_lualatex_command( + mock_tex_path, shell_escape=False, output_dir="build" + ) + assert "-output-directory" in cmd assert "build" in cmd @@ -139,40 +157,40 @@ def test_build_lualatex_command_with_output_dir(self, compiler, mock_tex_path): def test_compile_once_routes_to_latexmk(self, compiler, mock_tex_path): """Test that 'latexmk' engine routes to _run_latexmk""" - with patch.object(compiler, '_run_latexmk', return_value=True) as mock_run: + with patch.object(compiler, "_run_latexmk", return_value=True) as mock_run: result = compiler._compile_once(mock_tex_path, "latexmk", False, None) - + mock_run.assert_called_once_with(mock_tex_path, False, None) assert result is True def test_compile_once_routes_to_pdflatex(self, compiler, mock_tex_path): """Test that 'pdflatex' engine routes to _run_pdflatex""" - with patch.object(compiler, '_run_pdflatex', return_value=True) as mock_run: + with patch.object(compiler, "_run_pdflatex", return_value=True) as mock_run: result = compiler._compile_once(mock_tex_path, "pdflatex", False, None) - + mock_run.assert_called_once_with(mock_tex_path, False, None) assert result is True def test_compile_once_routes_to_xelatex(self, compiler, mock_tex_path): """Test that 'xelatex' engine routes to _run_xelatex""" - with patch.object(compiler, '_run_xelatex', return_value=True) as mock_run: + with patch.object(compiler, "_run_xelatex", return_value=True) as mock_run: result = compiler._compile_once(mock_tex_path, "xelatex", False, None) - + mock_run.assert_called_once_with(mock_tex_path, False, None) assert result is True def test_compile_once_routes_to_lualatex(self, compiler, mock_tex_path): """Test that 'lualatex' engine routes to _run_lualatex""" - with patch.object(compiler, '_run_lualatex', return_value=True) as mock_run: + with patch.object(compiler, "_run_lualatex", return_value=True) as mock_run: result = compiler._compile_once(mock_tex_path, "lualatex", False, None) - + mock_run.assert_called_once_with(mock_tex_path, False, None) assert result is True def test_compile_once_unknown_engine_returns_false(self, compiler, mock_tex_path): """Test that unknown engine returns False""" result = compiler._compile_once(mock_tex_path, "unknown_engine", False, None) - + assert result is False # --- Test _compile_watch restrictions --- @@ -180,42 +198,42 @@ def test_compile_once_unknown_engine_returns_false(self, compiler, mock_tex_path def test_compile_watch_rejects_pdflatex(self, compiler, mock_tex_path): """Test that watch mode rejects pdflatex engine""" result = compiler._compile_watch(mock_tex_path, "pdflatex", False, None) - + assert result is False def test_compile_watch_rejects_xelatex(self, compiler, mock_tex_path): """Test that watch mode rejects xelatex engine""" result = compiler._compile_watch(mock_tex_path, "xelatex", False, None) - + assert result is False def test_compile_watch_rejects_lualatex(self, compiler, mock_tex_path): """Test that watch mode rejects lualatex engine""" result = compiler._compile_watch(mock_tex_path, "lualatex", False, None) - + assert result is False # --- Test check_dependencies --- def test_check_dependencies_includes_xelatex(self, compiler): """Test that dependency check includes xelatex""" - with patch.object(compiler, '_check_command', return_value=True): + with patch.object(compiler, "_check_command", return_value=True): deps = compiler.check_dependencies() - + assert "xelatex" in deps def test_check_dependencies_includes_lualatex(self, compiler): """Test that dependency check includes lualatex""" - with patch.object(compiler, '_check_command', return_value=True): + with patch.object(compiler, "_check_command", return_value=True): deps = compiler.check_dependencies() - + assert "lualatex" in deps def test_check_dependencies_all_engines(self, compiler): """Test that all expected engines are in dependency check""" - with patch.object(compiler, '_check_command', return_value=True): + with patch.object(compiler, "_check_command", return_value=True): deps = compiler.check_dependencies() - + expected_tools = ["latexmk", "pdflatex", "xelatex", "lualatex", "bibtex"] for tool in expected_tools: assert tool in deps, f"Missing tool: {tool}" @@ -234,75 +252,89 @@ def compiler(self): def mock_tex_path(self, tmp_path): """Create a mock .tex file""" tex_file = tmp_path / "test.tex" - tex_file.write_text(r"\documentclass{article}\begin{document}Hello\end{document}") + tex_file.write_text( + r"\documentclass{article}\begin{document}Hello\end{document}" + ) return tex_file - @patch('subprocess.run') + @patch("subprocess.run") def test_run_xelatex_success(self, mock_run, compiler, mock_tex_path, tmp_path): """Test successful xelatex compilation""" # Create mock PDF file pdf_file = tmp_path / "test.pdf" pdf_file.write_bytes(b"%PDF-1.4 mock pdf content") - + # Mock successful subprocess run mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - - result = compiler._run_xelatex(mock_tex_path, shell_escape=False, output_dir=None) - + + result = compiler._run_xelatex( + mock_tex_path, shell_escape=False, output_dir=None + ) + # Should have been called 3 times (3 passes) assert mock_run.call_count == 3 assert result is True - @patch('subprocess.run') + @patch("subprocess.run") def test_run_lualatex_success(self, mock_run, compiler, mock_tex_path, tmp_path): """Test successful lualatex compilation""" # Create mock PDF file pdf_file = tmp_path / "test.pdf" pdf_file.write_bytes(b"%PDF-1.4 mock pdf content") - + # Mock successful subprocess run mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - - result = compiler._run_lualatex(mock_tex_path, shell_escape=False, output_dir=None) - + + result = compiler._run_lualatex( + mock_tex_path, shell_escape=False, output_dir=None + ) + # Should have been called 3 times (3 passes) assert mock_run.call_count == 3 assert result is True - @patch('subprocess.run') + @patch("subprocess.run") def test_run_xelatex_failure(self, mock_run, compiler, mock_tex_path): """Test xelatex compilation failure""" # Mock failed subprocess run mock_run.return_value = MagicMock(returncode=1, stdout="Error!", stderr="") - - result = compiler._run_xelatex(mock_tex_path, shell_escape=False, output_dir=None) - + + result = compiler._run_xelatex( + mock_tex_path, shell_escape=False, output_dir=None + ) + assert result is False - @patch('subprocess.run') + @patch("subprocess.run") def test_run_lualatex_failure(self, mock_run, compiler, mock_tex_path): """Test lualatex compilation failure""" # Mock failed subprocess run mock_run.return_value = MagicMock(returncode=1, stdout="Error!", stderr="") - - result = compiler._run_lualatex(mock_tex_path, shell_escape=False, output_dir=None) - + + result = compiler._run_lualatex( + mock_tex_path, shell_escape=False, output_dir=None + ) + assert result is False - @patch('subprocess.run') + @patch("subprocess.run") def test_run_xelatex_timeout(self, mock_run, compiler, mock_tex_path): """Test xelatex compilation timeout""" mock_run.side_effect = subprocess.TimeoutExpired(cmd="xelatex", timeout=120) - - result = compiler._run_xelatex(mock_tex_path, shell_escape=False, output_dir=None) - + + result = compiler._run_xelatex( + mock_tex_path, shell_escape=False, output_dir=None + ) + assert result is False - @patch('subprocess.run') + @patch("subprocess.run") def test_run_lualatex_timeout(self, mock_run, compiler, mock_tex_path): """Test lualatex compilation timeout""" mock_run.side_effect = subprocess.TimeoutExpired(cmd="lualatex", timeout=120) - - result = compiler._run_lualatex(mock_tex_path, shell_escape=False, output_dir=None) - + + result = compiler._run_lualatex( + mock_tex_path, shell_escape=False, output_dir=None + ) + assert result is False diff --git a/tests/test_themes.py b/tests/test_themes.py new file mode 100644 index 0000000..2258328 --- /dev/null +++ b/tests/test_themes.py @@ -0,0 +1,384 @@ +""" +Tests for theme installation module +""" + +import pytest +import tempfile +import zipfile +from pathlib import Path +from unittest.mock import patch, MagicMock +from io import BytesIO + +from article_cli.themes import ( + ThemeInstaller, + DEFAULT_THEME_SOURCES, + get_available_themes, + get_theme_info, +) + + +class TestThemeInstaller: + """Tests for ThemeInstaller class""" + + def test_init_default_directory(self): + """Test default themes directory is current directory""" + installer = ThemeInstaller() + assert installer.themes_dir == Path(".") + + def test_init_custom_directory(self): + """Test custom themes directory""" + custom_dir = Path("/custom/themes") + installer = ThemeInstaller(themes_dir=custom_dir) + assert installer.themes_dir == custom_dir + + def test_init_custom_sources(self): + """Test custom theme sources""" + custom_sources = { + "custom-theme": { + "url": "https://example.com/theme.zip", + "description": "Custom theme", + } + } + installer = ThemeInstaller(sources=custom_sources) + assert "custom-theme" in installer.sources + assert "numpex" not in installer.sources + + def test_init_default_sources(self): + """Test default sources are used when none specified""" + installer = ThemeInstaller() + assert installer.sources == DEFAULT_THEME_SOURCES + + def test_list_available(self): + """Test listing available themes""" + installer = ThemeInstaller() + available = installer.list_available() + assert len(available) > 0 + assert any(t["name"] == "numpex" for t in available) + + def test_list_available_custom_sources(self): + """Test listing available themes with custom sources""" + custom_sources = { + "theme1": {"url": "url1", "description": "Theme 1"}, + "theme2": {"url": "url2", "description": "Theme 2"}, + } + installer = ThemeInstaller(sources=custom_sources) + available = installer.list_available() + assert len(available) == 2 + names = [t["name"] for t in available] + assert "theme1" in names + assert "theme2" in names + + def test_list_installed_empty(self): + """Test listing installed themes when none are installed""" + with tempfile.TemporaryDirectory() as tmp_dir: + installer = ThemeInstaller(themes_dir=Path(tmp_dir)) + installed = installer.list_installed() + assert installed == [] + + def test_list_installed_with_theme(self): + """Test listing installed themes when theme is present""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + # Create theme file + (tmp_path / "beamerthemenumpex.sty").write_text("% theme") + + installer = ThemeInstaller(themes_dir=tmp_path) + installed = installer.list_installed() + + assert len(installed) == 1 + assert installed[0]["name"] == "numpex" + + def test_install_theme_unknown(self): + """Test installing unknown theme returns error""" + with tempfile.TemporaryDirectory() as tmp_dir: + installer = ThemeInstaller(themes_dir=Path(tmp_dir)) + result = installer.install_theme("nonexistent-theme") + assert result is False + + def test_install_theme_no_url(self): + """Test installing theme with no URL configured""" + custom_sources = {"broken-theme": {"description": "No URL"}} + with tempfile.TemporaryDirectory() as tmp_dir: + installer = ThemeInstaller(themes_dir=Path(tmp_dir), sources=custom_sources) + result = installer.install_theme("broken-theme") + assert result is False + + def test_install_theme_already_installed(self): + """Test installing theme that's already installed (no force)""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + # Create existing theme file + (tmp_path / "beamerthemenumpex.sty").write_text("% existing theme") + + installer = ThemeInstaller(themes_dir=tmp_path) + result = installer.install_theme("numpex", force=False) + + # Should return True (already installed) + assert result is True + # File should be unchanged + assert ( + tmp_path / "beamerthemenumpex.sty" + ).read_text() == "% existing theme" + + @patch.object(ThemeInstaller, "_download_and_extract_theme") + def test_install_theme_success(self, mock_download): + """Test successful theme installation""" + with tempfile.TemporaryDirectory() as tmp_dir: + installer = ThemeInstaller(themes_dir=Path(tmp_dir)) + + result = installer.install_theme("numpex", force=True) + + mock_download.assert_called_once() + assert result is True + + @patch.object(ThemeInstaller, "_download_and_extract_theme") + def test_install_theme_download_failure(self, mock_download): + """Test theme installation with download failure""" + mock_download.side_effect = RuntimeError("Network error") + + with tempfile.TemporaryDirectory() as tmp_dir: + installer = ThemeInstaller(themes_dir=Path(tmp_dir)) + result = installer.install_theme("numpex", force=True) + + assert result is False + + @patch.object(ThemeInstaller, "_download_and_extract_theme") + def test_install_from_url(self, mock_download): + """Test installing theme from custom URL""" + with tempfile.TemporaryDirectory() as tmp_dir: + installer = ThemeInstaller(themes_dir=Path(tmp_dir)) + + result = installer.install_from_url( + name="custom", url="https://example.com/theme.zip" + ) + + mock_download.assert_called_once() + assert result is True + + def test_extract_theme_files_sty_only(self): + """Test extracting only .sty files from archive""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + + # Create test zip with various files + zip_path = tmp_path / "test.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("repo-main/beamerthemetest.sty", "% style") + zf.writestr("repo-main/README.md", "# Readme") + zf.writestr("repo-main/other.txt", "other") + + output_dir = tmp_path / "output" + output_dir.mkdir() + + installer = ThemeInstaller(themes_dir=output_dir) + installer._extract_theme_files(zip_path, [], []) + + # Only .sty file should be extracted + assert (output_dir / "beamerthemetest.sty").exists() + assert not (output_dir / "README.md").exists() + assert not (output_dir / "other.txt").exists() + + def test_extract_theme_files_specific_files(self): + """Test extracting specific files from archive""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + + # Create test zip with theme files + zip_path = tmp_path / "test.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("repo-main/beamerthemenumpex.sty", "% main") + zf.writestr("repo-main/beamercolorthemenumpex.sty", "% color") + zf.writestr("repo-main/other.sty", "% other") + + output_dir = tmp_path / "output" + output_dir.mkdir() + + installer = ThemeInstaller(themes_dir=output_dir) + installer._extract_theme_files( + zip_path, ["beamerthemenumpex.sty", "beamercolorthemenumpex.sty"], [] + ) + + # Only specified files should be extracted + assert (output_dir / "beamerthemenumpex.sty").exists() + assert (output_dir / "beamercolorthemenumpex.sty").exists() + assert not (output_dir / "other.sty").exists() + + def test_extract_theme_files_with_directories(self): + """Test extracting directories from archive""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + + # Create test zip with images directory + zip_path = tmp_path / "test.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("repo-main/beamerthemenumpex.sty", "% main") + zf.writestr("repo-main/images/logo.png", "PNG data") + zf.writestr("repo-main/images/background.png", "PNG data") + zf.writestr("repo-main/other/file.txt", "other") + + output_dir = tmp_path / "output" + output_dir.mkdir() + + installer = ThemeInstaller(themes_dir=output_dir) + installer._extract_theme_files( + zip_path, ["beamerthemenumpex.sty"], ["images"] + ) + + # Theme file and images should be extracted + assert (output_dir / "beamerthemenumpex.sty").exists() + assert (output_dir / "images" / "logo.png").exists() + assert (output_dir / "images" / "background.png").exists() + # Other directory should not be extracted + assert not (output_dir / "other").exists() + + def test_extract_theme_files_bad_zip(self): + """Test extracting from invalid zip file""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + + # Create invalid zip + zip_path = tmp_path / "bad.zip" + zip_path.write_text("not a zip file") + + output_dir = tmp_path / "output" + output_dir.mkdir() + + installer = ThemeInstaller(themes_dir=output_dir) + + with pytest.raises(RuntimeError, match="Invalid or corrupted zip file"): + installer._extract_theme_files(zip_path, [], []) + + +class TestDefaultThemeSources: + """Tests for default theme sources""" + + def test_numpex_theme_in_defaults(self): + """Test numpex theme is in default sources""" + assert "numpex" in DEFAULT_THEME_SOURCES + + def test_numpex_theme_has_url(self): + """Test numpex theme has valid URL""" + numpex = DEFAULT_THEME_SOURCES["numpex"] + assert "url" in numpex + assert numpex["url"].startswith("https://") + + def test_numpex_theme_has_files(self): + """Test numpex theme specifies required files""" + numpex = DEFAULT_THEME_SOURCES["numpex"] + assert "files" in numpex + files = numpex["files"] + assert "beamerthemenumpex.sty" in files + assert "beamercolorthemenumpex.sty" in files + assert "beamerfontthemenumpex.sty" in files + + def test_numpex_theme_has_directories(self): + """Test numpex theme specifies directories""" + numpex = DEFAULT_THEME_SOURCES["numpex"] + assert "directories" in numpex + assert "images" in numpex["directories"] + + def test_numpex_theme_requires_fonts(self): + """Test numpex theme indicates font requirement""" + numpex = DEFAULT_THEME_SOURCES["numpex"] + assert numpex.get("requires_fonts") is True + + def test_numpex_theme_engine(self): + """Test numpex theme specifies xelatex engine""" + numpex = DEFAULT_THEME_SOURCES["numpex"] + assert numpex.get("engine") == "xelatex" + + +class TestHelperFunctions: + """Tests for module-level helper functions""" + + def test_get_available_themes(self): + """Test get_available_themes returns copy of defaults""" + themes = get_available_themes() + assert themes == DEFAULT_THEME_SOURCES + # Should be a copy, not the original + themes["new"] = {} + assert "new" not in DEFAULT_THEME_SOURCES + + def test_get_theme_info_known(self): + """Test get_theme_info for known theme""" + info = get_theme_info("numpex") + assert info is not None + assert "url" in info + + def test_get_theme_info_unknown(self): + """Test get_theme_info for unknown theme""" + info = get_theme_info("nonexistent") + assert info is None + + +class TestThemeInstallerDownload: + """Tests for download functionality""" + + @patch("article_cli.themes.urlopen") + def test_download_file_success(self, mock_urlopen): + """Test successful file download""" + # Mock response + mock_response = MagicMock() + mock_response.headers = {"Content-Length": "100"} + mock_response.read.side_effect = [b"x" * 100, b""] + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_response + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + dest = tmp_path / "downloaded.zip" + + installer = ThemeInstaller() + installer._download_file("https://example.com/file.zip", dest) + + assert dest.exists() + assert dest.read_bytes() == b"x" * 100 + + @patch("article_cli.themes.urlopen") + def test_download_file_http_error(self, mock_urlopen): + """Test download with HTTP error""" + from urllib.error import HTTPError + + mock_urlopen.side_effect = HTTPError( + "https://example.com", 404, "Not Found", {}, None + ) + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + dest = tmp_path / "downloaded.zip" + + installer = ThemeInstaller() + + with pytest.raises(RuntimeError, match="HTTP error 404"): + installer._download_file("https://example.com/file.zip", dest) + + @patch("article_cli.themes.urlopen") + def test_download_file_url_error(self, mock_urlopen): + """Test download with URL error""" + from urllib.error import URLError + + mock_urlopen.side_effect = URLError("Connection refused") + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + dest = tmp_path / "downloaded.zip" + + installer = ThemeInstaller() + + with pytest.raises(RuntimeError, match="URL error"): + installer._download_file("https://example.com/file.zip", dest) + + @patch("article_cli.themes.urlopen") + def test_download_file_timeout(self, mock_urlopen): + """Test download with timeout""" + mock_urlopen.side_effect = TimeoutError() + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + dest = tmp_path / "downloaded.zip" + + installer = ThemeInstaller() + + with pytest.raises(RuntimeError, match="Download timed out"): + installer._download_file("https://example.com/file.zip", dest)