From 02ecf61ab781a5a50817431159471e47358d7bbd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 15:30:52 +0000 Subject: [PATCH 1/2] Add integration tests, strengthen CI gates, add contribution guidelines - Add comprehensive CLI integration tests with smoke tests and mocked workflows - Fix pip-audit to fail on vulnerabilities (remove || true) - Add coverage threshold (50%) and update codecov action - Add separate integration test job in CI - Add CONTRIBUTING.md with development setup and coding standards --- .github/workflows/ci.yml | 33 ++- CONTRIBUTING.md | 248 +++++++++++++++++ tests/test_integration.py | 561 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 838 insertions(+), 4 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 tests/test_integration.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cae9005..f24a4c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,14 +72,15 @@ jobs: python -m spacy download en_core_web_sm - name: Run tests - run: pytest tests/ -v --cov=quantcoder --cov-report=xml + run: pytest tests/ -v --cov=quantcoder --cov-report=xml --cov-report=term --cov-fail-under=50 - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 if: matrix.python-version == '3.11' with: files: ./coverage.xml - fail_ci_if_error: false + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} security: name: Security Scan @@ -98,7 +99,9 @@ jobs: pip install pip-audit - name: Run pip-audit - run: pip-audit --require-hashes=false || true + run: | + pip install -e ".[dev]" + pip-audit --require-hashes=false --strict secret-scan: name: Secret Scanning @@ -112,3 +115,25 @@ jobs: uses: trufflesecurity/trufflehog@main with: extra_args: --only-verified + + integration: + name: Integration Tests + runs-on: ubuntu-latest + needs: [lint, type-check] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + pip install pytest-cov pytest-mock + python -m spacy download en_core_web_sm + + - name: Run integration tests + run: pytest tests/test_integration.py -v -m "integration or not integration" --tb=short diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9604ad8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,248 @@ +# Contributing to QuantCoder CLI + +Thank you for your interest in contributing to QuantCoder CLI! This document provides guidelines and instructions for contributing. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [Making Changes](#making-changes) +- [Pull Request Process](#pull-request-process) +- [Coding Standards](#coding-standards) +- [Testing](#testing) +- [Documentation](#documentation) + +## Code of Conduct + +This project adheres to the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. + +## Getting Started + +1. Fork the repository on GitHub +2. Clone your fork locally +3. Set up the development environment +4. Create a branch for your changes +5. Make your changes and test them +6. Submit a pull request + +## Development Setup + +### Prerequisites + +- Python 3.10 or higher +- Git +- A virtual environment tool (venv, conda, etc.) + +### Installation + +```bash +# Clone your fork +git clone https://github.com/YOUR_USERNAME/quantcoder-cli.git +cd quantcoder-cli + +# Create virtual environment +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install in development mode with dev dependencies +pip install -e ".[dev]" + +# Download required spacy model +python -m spacy download en_core_web_sm + +# Verify installation +quantcoder --help +``` + +### Running Tests + +```bash +# Run all tests +pytest tests/ -v + +# Run with coverage +pytest tests/ -v --cov=quantcoder --cov-report=term + +# Run only integration tests +pytest tests/ -v -m integration + +# Run only unit tests (exclude integration) +pytest tests/ -v -m "not integration" +``` + +### Code Quality Tools + +```bash +# Format code with Black +black . + +# Lint with Ruff +ruff check . + +# Type checking with mypy +mypy quantcoder --ignore-missing-imports + +# Security audit +pip-audit --require-hashes=false +``` + +## Making Changes + +### Branch Naming + +Use descriptive branch names: + +- `feature/add-new-indicator` - For new features +- `fix/search-timeout-issue` - For bug fixes +- `docs/update-readme` - For documentation +- `refactor/simplify-agent-logic` - For code refactoring + +### Commit Messages + +Follow conventional commit format: + +``` +type(scope): brief description + +Longer description if needed. + +- Bullet points for multiple changes +- Keep lines under 72 characters +``` + +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` + +Examples: +``` +feat(cli): add --timeout option to search command +fix(tools): handle network timeout in download tool +docs(readme): update installation instructions +test(integration): add CLI smoke tests +``` + +## Pull Request Process + +1. **Before submitting:** + - Ensure all tests pass: `pytest tests/ -v` + - Run linting: `black . && ruff check .` + - Run type checking: `mypy quantcoder` + - Update documentation if needed + +2. **PR Description:** + - Clearly describe what changes you made + - Reference any related issues + - Include screenshots for UI changes + - List any breaking changes + +3. **Review Process:** + - PRs require at least one approval + - Address all review comments + - Keep PRs focused and reasonably sized + +4. **After Merge:** + - Delete your feature branch + - Update your fork's main branch + +## Coding Standards + +### Python Style + +- Follow [PEP 8](https://pep8.org/) style guide +- Use [Black](https://black.readthedocs.io/) for formatting (line length: 100) +- Use type hints for function signatures +- Write docstrings for public functions and classes + +### Code Organization + +``` +quantcoder/ +├── __init__.py +├── cli.py # CLI entry point +├── config.py # Configuration management +├── chat.py # Interactive chat +├── agents/ # Multi-agent system +├── tools/ # Pluggable tools +├── llm/ # LLM provider abstraction +├── evolver/ # Evolution engine +├── autonomous/ # Autonomous mode +├── library/ # Library builder +└── core/ # Core utilities +``` + +### Error Handling + +- Use specific exception types (not bare `except:`) +- Provide helpful error messages +- Log errors with appropriate severity levels + +### Security + +- Never commit secrets or API keys +- Validate user inputs +- Use parameterized queries/requests +- Follow OWASP guidelines + +## Testing + +### Test Organization + +- Unit tests: `tests/test_*.py` +- Integration tests: `tests/test_integration.py` +- Fixtures: `tests/conftest.py` + +### Writing Tests + +```python +import pytest +from quantcoder.tools import SearchArticlesTool + +class TestSearchTool: + """Tests for the search tool.""" + + def test_search_returns_results(self, mock_config): + """Test that search returns expected results.""" + tool = SearchArticlesTool(mock_config) + result = tool.execute(query="momentum", max_results=5) + assert result.success + assert len(result.data) <= 5 + + @pytest.mark.integration + def test_search_integration(self): + """Integration test with real API (marked for selective running).""" + # This test hits real APIs + pass +``` + +### Test Markers + +- `@pytest.mark.slow` - Long-running tests +- `@pytest.mark.integration` - Integration tests +- Tests without markers run by default + +## Documentation + +### Code Documentation + +- Add docstrings to all public functions/classes +- Use Google-style or NumPy-style docstrings +- Keep documentation up to date with code changes + +### User Documentation + +- Update README.md for user-facing changes +- Add examples for new features +- Document configuration options + +### Architecture Documentation + +- Update ARCHITECTURE.md for structural changes +- Document design decisions in ADRs if significant + +## Questions? + +- Open an issue for questions or discussions +- Tag maintainers for urgent issues +- Check existing issues before creating new ones + +Thank you for contributing to QuantCoder CLI! diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..8af889c --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,561 @@ +"""Integration tests for QuantCoder CLI. + +These tests verify end-to-end functionality of CLI commands with mocked external services. +""" + +import json +import os +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch +from click.testing import CliRunner + +from quantcoder.cli import main + + +@pytest.fixture +def cli_runner(): + """Create a Click CLI test runner.""" + return CliRunner() + + +@pytest.fixture +def mock_env(tmp_path, monkeypatch): + """Set up mock environment with API keys and temp directories.""" + # Set up environment variables + monkeypatch.setenv("OPENAI_API_KEY", "sk-test-key-12345") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test-12345") + + # Create temp directories + home_dir = tmp_path / ".quantcoder" + home_dir.mkdir() + downloads_dir = tmp_path / "downloads" + downloads_dir.mkdir() + generated_dir = tmp_path / "generated_code" + generated_dir.mkdir() + + return { + "home_dir": home_dir, + "downloads_dir": downloads_dir, + "generated_dir": generated_dir, + "tmp_path": tmp_path, + } + + +# ============================================================================= +# CLI SMOKE TESTS +# ============================================================================= + + +class TestCLISmoke: + """Smoke tests for basic CLI functionality.""" + + def test_help_command(self, cli_runner): + """Test that --help displays usage information.""" + result = cli_runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "QuantCoder" in result.output + assert "AI-powered CLI" in result.output + + def test_version_command(self, cli_runner): + """Test that version command shows version info.""" + with patch("quantcoder.cli.Config") as mock_config_class: + mock_config = MagicMock() + mock_config.api_key = "sk-test-key" + mock_config.load_api_key.return_value = "sk-test-key" + mock_config_class.load.return_value = mock_config + + result = cli_runner.invoke(main, ["version"]) + assert result.exit_code == 0 + assert "QuantCoder" in result.output or "2.0" in result.output + + def test_search_help(self, cli_runner): + """Test that search --help shows search options.""" + result = cli_runner.invoke(main, ["search", "--help"]) + assert result.exit_code == 0 + assert "Search for academic articles" in result.output + assert "--num" in result.output + + def test_download_help(self, cli_runner): + """Test that download --help shows download options.""" + result = cli_runner.invoke(main, ["download", "--help"]) + assert result.exit_code == 0 + assert "Download" in result.output + + def test_summarize_help(self, cli_runner): + """Test that summarize --help shows summarize options.""" + result = cli_runner.invoke(main, ["summarize", "--help"]) + assert result.exit_code == 0 + assert "Summarize" in result.output + + def test_generate_help(self, cli_runner): + """Test that generate --help shows generate options.""" + result = cli_runner.invoke(main, ["generate", "--help"]) + assert result.exit_code == 0 + assert "Generate" in result.output + assert "--max-attempts" in result.output + + def test_validate_help(self, cli_runner): + """Test that validate --help shows validate options.""" + result = cli_runner.invoke(main, ["validate", "--help"]) + assert result.exit_code == 0 + assert "Validate" in result.output + assert "--local-only" in result.output + + def test_backtest_help(self, cli_runner): + """Test that backtest --help shows backtest options.""" + result = cli_runner.invoke(main, ["backtest", "--help"]) + assert result.exit_code == 0 + assert "backtest" in result.output.lower() + assert "--start" in result.output + assert "--end" in result.output + + def test_auto_help(self, cli_runner): + """Test that auto --help shows autonomous mode options.""" + result = cli_runner.invoke(main, ["auto", "--help"]) + assert result.exit_code == 0 + assert "Autonomous" in result.output or "auto" in result.output.lower() + + def test_library_help(self, cli_runner): + """Test that library --help shows library builder options.""" + result = cli_runner.invoke(main, ["library", "--help"]) + assert result.exit_code == 0 + assert "library" in result.output.lower() + + def test_evolve_help(self, cli_runner): + """Test that evolve --help shows evolution options.""" + result = cli_runner.invoke(main, ["evolve", "--help"]) + assert result.exit_code == 0 + assert "evolve" in result.output.lower() or "AlphaEvolve" in result.output + + def test_config_show_help(self, cli_runner): + """Test that config-show --help shows config options.""" + result = cli_runner.invoke(main, ["config-show", "--help"]) + assert result.exit_code == 0 + assert "configuration" in result.output.lower() + + +# ============================================================================= +# SEARCH COMMAND INTEGRATION TESTS +# ============================================================================= + + +class TestSearchCommand: + """Integration tests for the search command.""" + + @pytest.mark.integration + def test_search_with_mocked_api(self, cli_runner): + """Test search command with mocked CrossRef API.""" + mock_articles = [ + { + "title": "Momentum Trading Strategies", + "authors": "John Doe, Jane Smith", + "published": "2023", + "doi": "10.1234/test.001", + }, + { + "title": "Mean Reversion in Financial Markets", + "authors": "Alice Brown", + "published": "2022", + "doi": "10.1234/test.002", + }, + ] + + with patch("quantcoder.cli.Config") as mock_config_class: + mock_config = MagicMock() + mock_config.api_key = "sk-test-key" + mock_config.load_api_key.return_value = "sk-test-key" + mock_config_class.load.return_value = mock_config + + with patch("quantcoder.cli.SearchArticlesTool") as mock_tool_class: + mock_tool = MagicMock() + mock_result = MagicMock() + mock_result.success = True + mock_result.message = "Found 2 articles" + mock_result.data = mock_articles + mock_tool.execute.return_value = mock_result + mock_tool_class.return_value = mock_tool + + result = cli_runner.invoke(main, ["search", "momentum trading", "--num", "2"]) + + assert result.exit_code == 0 + assert "Found 2 articles" in result.output or "Momentum" in result.output + + @pytest.mark.integration + def test_search_no_results(self, cli_runner): + """Test search command when no results found.""" + with patch("quantcoder.cli.Config") as mock_config_class: + mock_config = MagicMock() + mock_config.api_key = "sk-test-key" + mock_config.load_api_key.return_value = "sk-test-key" + mock_config_class.load.return_value = mock_config + + with patch("quantcoder.cli.SearchArticlesTool") as mock_tool_class: + mock_tool = MagicMock() + mock_result = MagicMock() + mock_result.success = False + mock_result.error = "No articles found" + mock_tool.execute.return_value = mock_result + mock_tool_class.return_value = mock_tool + + result = cli_runner.invoke(main, ["search", "nonexistent topic xyz"]) + + assert "No articles found" in result.output or result.exit_code == 0 + + +# ============================================================================= +# GENERATE COMMAND INTEGRATION TESTS +# ============================================================================= + + +class TestGenerateCommand: + """Integration tests for the generate command.""" + + @pytest.mark.integration + def test_generate_with_mocked_llm(self, cli_runner): + """Test generate command with mocked LLM response.""" + mock_code = ''' +from AlgorithmImports import * + +class TestStrategy(QCAlgorithm): + def Initialize(self): + self.SetStartDate(2020, 1, 1) + self.SetCash(100000) + self.AddEquity("SPY", Resolution.Daily) + + def OnData(self, data): + if not self.Portfolio.Invested: + self.SetHoldings("SPY", 1.0) +''' + + with patch("quantcoder.cli.Config") as mock_config_class: + mock_config = MagicMock() + mock_config.api_key = "sk-test-key" + mock_config.load_api_key.return_value = "sk-test-key" + mock_config_class.load.return_value = mock_config + + with patch("quantcoder.cli.GenerateCodeTool") as mock_tool_class: + mock_tool = MagicMock() + mock_result = MagicMock() + mock_result.success = True + mock_result.message = "Generated algorithm successfully" + mock_result.data = { + "code": mock_code, + "summary": "A simple buy and hold strategy", + "path": "/tmp/algorithm_1.py", + } + mock_tool.execute.return_value = mock_result + mock_tool_class.return_value = mock_tool + + result = cli_runner.invoke(main, ["generate", "1"]) + + assert result.exit_code == 0 + assert "Generated" in result.output or "TestStrategy" in result.output + + +# ============================================================================= +# VALIDATE COMMAND INTEGRATION TESTS +# ============================================================================= + + +class TestValidateCommand: + """Integration tests for the validate command.""" + + @pytest.mark.integration + def test_validate_valid_code(self, cli_runner, tmp_path): + """Test validate command with valid Python code.""" + # Create a temporary file with valid code + code_file = tmp_path / "test_algo.py" + code_file.write_text(''' +from AlgorithmImports import * + +class TestStrategy(QCAlgorithm): + def Initialize(self): + self.SetStartDate(2020, 1, 1) + self.SetCash(100000) + + def OnData(self, data): + pass +''') + + with patch("quantcoder.cli.Config") as mock_config_class: + mock_config = MagicMock() + mock_config.api_key = "sk-test-key" + mock_config.load_api_key.return_value = "sk-test-key" + mock_config_class.load.return_value = mock_config + + with patch("quantcoder.cli.ValidateCodeTool") as mock_tool_class: + mock_tool = MagicMock() + mock_result = MagicMock() + mock_result.success = True + mock_result.message = "Code is valid" + mock_result.data = {"warnings": []} + mock_tool.execute.return_value = mock_result + mock_tool_class.return_value = mock_tool + + result = cli_runner.invoke(main, ["validate", str(code_file), "--local-only"]) + + assert result.exit_code == 0 + assert "valid" in result.output.lower() or "✓" in result.output + + @pytest.mark.integration + def test_validate_invalid_code(self, cli_runner, tmp_path): + """Test validate command with invalid Python code.""" + # Create a temporary file with invalid code + code_file = tmp_path / "invalid_algo.py" + code_file.write_text(''' +def broken_function( + # Missing closing parenthesis +''') + + with patch("quantcoder.cli.Config") as mock_config_class: + mock_config = MagicMock() + mock_config.api_key = "sk-test-key" + mock_config.load_api_key.return_value = "sk-test-key" + mock_config_class.load.return_value = mock_config + + with patch("quantcoder.cli.ValidateCodeTool") as mock_tool_class: + mock_tool = MagicMock() + mock_result = MagicMock() + mock_result.success = False + mock_result.error = "Syntax error in code" + mock_result.data = {"errors": ["SyntaxError: unexpected EOF"]} + mock_tool.execute.return_value = mock_result + mock_tool_class.return_value = mock_tool + + result = cli_runner.invoke(main, ["validate", str(code_file), "--local-only"]) + + assert "error" in result.output.lower() or "✗" in result.output + + +# ============================================================================= +# AUTO (AUTONOMOUS) COMMAND INTEGRATION TESTS +# ============================================================================= + + +class TestAutoCommand: + """Integration tests for the autonomous mode commands.""" + + def test_auto_start_help(self, cli_runner): + """Test auto start --help shows options.""" + result = cli_runner.invoke(main, ["auto", "start", "--help"]) + assert result.exit_code == 0 + assert "--query" in result.output + assert "--max-iterations" in result.output + assert "--demo" in result.output + + def test_auto_status_help(self, cli_runner): + """Test auto status --help shows options.""" + result = cli_runner.invoke(main, ["auto", "status", "--help"]) + assert result.exit_code == 0 + + def test_auto_report_help(self, cli_runner): + """Test auto report --help shows options.""" + result = cli_runner.invoke(main, ["auto", "report", "--help"]) + assert result.exit_code == 0 + assert "--format" in result.output + + +# ============================================================================= +# LIBRARY COMMAND INTEGRATION TESTS +# ============================================================================= + + +class TestLibraryCommand: + """Integration tests for the library builder commands.""" + + def test_library_build_help(self, cli_runner): + """Test library build --help shows options.""" + result = cli_runner.invoke(main, ["library", "build", "--help"]) + assert result.exit_code == 0 + assert "--comprehensive" in result.output + assert "--max-hours" in result.output + assert "--demo" in result.output + + def test_library_status_help(self, cli_runner): + """Test library status --help shows options.""" + result = cli_runner.invoke(main, ["library", "status", "--help"]) + assert result.exit_code == 0 + + def test_library_export_help(self, cli_runner): + """Test library export --help shows options.""" + result = cli_runner.invoke(main, ["library", "export", "--help"]) + assert result.exit_code == 0 + assert "--format" in result.output + + +# ============================================================================= +# EVOLVE COMMAND INTEGRATION TESTS +# ============================================================================= + + +class TestEvolveCommand: + """Integration tests for the evolve commands.""" + + def test_evolve_start_help(self, cli_runner): + """Test evolve start --help shows options.""" + result = cli_runner.invoke(main, ["evolve", "start", "--help"]) + assert result.exit_code == 0 + assert "--gens" in result.output or "--max_generations" in result.output or "generations" in result.output.lower() + + def test_evolve_list_help(self, cli_runner): + """Test evolve list --help shows options.""" + result = cli_runner.invoke(main, ["evolve", "list", "--help"]) + assert result.exit_code == 0 + + def test_evolve_show_help(self, cli_runner): + """Test evolve show --help shows options.""" + result = cli_runner.invoke(main, ["evolve", "show", "--help"]) + assert result.exit_code == 0 + assert "EVOLUTION_ID" in result.output + + def test_evolve_export_help(self, cli_runner): + """Test evolve export --help shows options.""" + result = cli_runner.invoke(main, ["evolve", "export", "--help"]) + assert result.exit_code == 0 + assert "--output" in result.output + + +# ============================================================================= +# END-TO-END WORKFLOW TESTS +# ============================================================================= + + +class TestEndToEndWorkflow: + """Tests for complete workflows with mocked external services.""" + + @pytest.mark.integration + def test_search_to_generate_workflow(self, cli_runner, tmp_path): + """Test the search -> download -> summarize -> generate workflow.""" + # Mock search results + mock_articles = [ + { + "title": "RSI Momentum Strategy", + "authors": "Test Author", + "published": "2023", + "doi": "10.1234/test.001", + } + ] + + # Mock article summary + mock_summary = "This paper describes an RSI-based momentum strategy." + + # Mock generated code + mock_code = ''' +from AlgorithmImports import * + +class RSIMomentumStrategy(QCAlgorithm): + def Initialize(self): + self.SetStartDate(2020, 1, 1) + self.SetCash(100000) + self.symbol = self.AddEquity("SPY", Resolution.Daily).Symbol + self.rsi = self.RSI(self.symbol, 14) + + def OnData(self, data): + if self.rsi.Current.Value < 30: + self.SetHoldings(self.symbol, 1.0) + elif self.rsi.Current.Value > 70: + self.Liquidate() +''' + + with patch("quantcoder.cli.Config") as mock_config_class: + mock_config = MagicMock() + mock_config.api_key = "sk-test-key" + mock_config.load_api_key.return_value = "sk-test-key" + mock_config_class.load.return_value = mock_config + + # Step 1: Search + with patch("quantcoder.cli.SearchArticlesTool") as mock_search: + mock_tool = MagicMock() + mock_result = MagicMock() + mock_result.success = True + mock_result.message = "Found 1 article" + mock_result.data = mock_articles + mock_tool.execute.return_value = mock_result + mock_search.return_value = mock_tool + + result = cli_runner.invoke(main, ["search", "RSI momentum"]) + assert result.exit_code == 0 + + # Step 2: Generate (skipping download/summarize for brevity) + with patch("quantcoder.cli.GenerateCodeTool") as mock_generate: + mock_tool = MagicMock() + mock_result = MagicMock() + mock_result.success = True + mock_result.message = "Generated algorithm" + mock_result.data = { + "code": mock_code, + "summary": mock_summary, + "path": str(tmp_path / "algorithm_1.py"), + } + mock_tool.execute.return_value = mock_result + mock_generate.return_value = mock_tool + + result = cli_runner.invoke(main, ["generate", "1"]) + assert result.exit_code == 0 + + +# ============================================================================= +# ERROR HANDLING TESTS +# ============================================================================= + + +class TestErrorHandling: + """Tests for error handling scenarios.""" + + @pytest.mark.integration + def test_missing_api_key_graceful_error(self, cli_runner, monkeypatch): + """Test that missing API key produces a helpful error message.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + with patch("quantcoder.cli.Config") as mock_config_class: + mock_config = MagicMock() + mock_config.api_key = None + mock_config.load_api_key.return_value = None + mock_config_class.load.return_value = mock_config + + # The CLI should prompt for API key or show an error + result = cli_runner.invoke(main, ["search", "test"], input="\n") + # Either prompts for key or shows error - both are acceptable + assert result.exit_code in [0, 1] + + @pytest.mark.integration + def test_network_error_handling(self, cli_runner): + """Test handling of network errors.""" + with patch("quantcoder.cli.Config") as mock_config_class: + mock_config = MagicMock() + mock_config.api_key = "sk-test-key" + mock_config.load_api_key.return_value = "sk-test-key" + mock_config_class.load.return_value = mock_config + + with patch("quantcoder.cli.SearchArticlesTool") as mock_tool_class: + mock_tool = MagicMock() + mock_result = MagicMock() + mock_result.success = False + mock_result.error = "Network error: Connection timeout" + mock_tool.execute.return_value = mock_result + mock_tool_class.return_value = mock_tool + + result = cli_runner.invoke(main, ["search", "test query"]) + + assert "error" in result.output.lower() or "timeout" in result.output.lower() + + def test_invalid_article_id(self, cli_runner): + """Test handling of invalid article ID.""" + with patch("quantcoder.cli.Config") as mock_config_class: + mock_config = MagicMock() + mock_config.api_key = "sk-test-key" + mock_config.load_api_key.return_value = "sk-test-key" + mock_config_class.load.return_value = mock_config + + with patch("quantcoder.cli.DownloadArticleTool") as mock_tool_class: + mock_tool = MagicMock() + mock_result = MagicMock() + mock_result.success = False + mock_result.error = "Article not found" + mock_tool.execute.return_value = mock_result + mock_tool_class.return_value = mock_tool + + result = cli_runner.invoke(main, ["download", "999"]) + + assert "not found" in result.output.lower() or "error" in result.output.lower() or "✗" in result.output From 08a6e6939e02a60a2a25e35cf2e8e1f22e5ac3f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 16:23:10 +0000 Subject: [PATCH 2/2] Add reproducible builds, LLM provider extras, and HTTP retry/caching - Add requirements-lock.txt with pinned versions for reproducible builds - Add optional extras for LLM providers (openai, anthropic, mistral, all-llm) - Add http_utils.py with retry logic (exponential backoff) and response caching - Update article_tools.py to use new HTTP utilities for better reliability - Simplify code of conduct section in CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- pyproject.toml | 11 ++ quantcoder/core/http_utils.py | 303 ++++++++++++++++++++++++++++++ quantcoder/tools/article_tools.py | 46 +++-- requirements-lock.txt | 50 +++++ 5 files changed, 394 insertions(+), 18 deletions(-) create mode 100644 quantcoder/core/http_utils.py create mode 100644 requirements-lock.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9604ad8..8f86aad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Thank you for your interest in contributing to QuantCoder CLI! This document pro ## Code of Conduct -This project adheres to the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. +By participating in this project, you are expected to maintain a respectful and inclusive environment. Be kind, constructive, and professional in all interactions. ## Getting Started diff --git a/pyproject.toml b/pyproject.toml index edf7984..50924f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,17 @@ dependencies = [ ] [project.optional-dependencies] +# LLM Provider extras - install only what you need +openai = ["openai>=1.0.0"] +anthropic = ["anthropic>=0.18.0"] +mistral = ["mistralai>=0.1.0"] +all-llm = [ + "openai>=1.0.0", + "anthropic>=0.18.0", + "mistralai>=0.1.0", +] + +# Development dependencies dev = [ "pytest>=7.4.0", "pytest-cov>=4.0", diff --git a/quantcoder/core/http_utils.py b/quantcoder/core/http_utils.py new file mode 100644 index 0000000..ff79794 --- /dev/null +++ b/quantcoder/core/http_utils.py @@ -0,0 +1,303 @@ +"""HTTP utilities with retry logic and caching support.""" + +import hashlib +import json +import logging +import time +from pathlib import Path +from typing import Any, Dict, Optional +from functools import wraps + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +logger = logging.getLogger(__name__) + + +# Default configuration +DEFAULT_TIMEOUT = 30 # seconds +DEFAULT_RETRIES = 3 +DEFAULT_BACKOFF_FACTOR = 0.5 # exponential backoff: 0.5, 1, 2 seconds +DEFAULT_CACHE_TTL = 3600 # 1 hour in seconds + + +def create_session_with_retries( + retries: int = DEFAULT_RETRIES, + backoff_factor: float = DEFAULT_BACKOFF_FACTOR, + status_forcelist: tuple = (429, 500, 502, 503, 504), +) -> requests.Session: + """ + Create a requests Session with automatic retry support. + + Args: + retries: Number of retries for failed requests + backoff_factor: Factor for exponential backoff between retries + status_forcelist: HTTP status codes that trigger a retry + + Returns: + Configured requests.Session object + """ + session = requests.Session() + + retry_strategy = Retry( + total=retries, + backoff_factor=backoff_factor, + status_forcelist=status_forcelist, + allowed_methods=["HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS", "TRACE"], + raise_on_status=False, + ) + + adapter = HTTPAdapter(max_retries=retry_strategy) + session.mount("http://", adapter) + session.mount("https://", adapter) + + return session + + +def make_request_with_retry( + url: str, + method: str = "GET", + headers: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + json_data: Optional[Dict[str, Any]] = None, + timeout: int = DEFAULT_TIMEOUT, + retries: int = DEFAULT_RETRIES, + backoff_factor: float = DEFAULT_BACKOFF_FACTOR, +) -> requests.Response: + """ + Make an HTTP request with automatic retry on failure. + + Args: + url: The URL to request + method: HTTP method (GET, POST, etc.) + headers: Optional headers dict + params: Optional query parameters + data: Optional form data + json_data: Optional JSON body + timeout: Request timeout in seconds + retries: Number of retry attempts + backoff_factor: Exponential backoff factor + + Returns: + requests.Response object + + Raises: + requests.exceptions.RequestException: If all retries fail + """ + session = create_session_with_retries(retries, backoff_factor) + + default_headers = { + "User-Agent": "QuantCoder/2.0 (mailto:smr.laignel@gmail.com)" + } + if headers: + default_headers.update(headers) + + try: + response = session.request( + method=method, + url=url, + headers=default_headers, + params=params, + data=data, + json=json_data, + timeout=timeout, + ) + return response + finally: + session.close() + + +class ResponseCache: + """Simple file-based cache for HTTP responses.""" + + def __init__(self, cache_dir: Optional[Path] = None, ttl: int = DEFAULT_CACHE_TTL): + """ + Initialize the response cache. + + Args: + cache_dir: Directory to store cache files + ttl: Time-to-live for cache entries in seconds + """ + self.cache_dir = cache_dir or Path.home() / ".quantcoder" / "cache" + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.ttl = ttl + + def _get_cache_key(self, url: str, params: Optional[Dict] = None) -> str: + """Generate a cache key from URL and params.""" + cache_input = url + if params: + cache_input += json.dumps(params, sort_keys=True) + return hashlib.sha256(cache_input.encode()).hexdigest() + + def _get_cache_path(self, cache_key: str) -> Path: + """Get the file path for a cache key.""" + return self.cache_dir / f"{cache_key}.json" + + def get(self, url: str, params: Optional[Dict] = None) -> Optional[Dict[str, Any]]: + """ + Get a cached response if it exists and is not expired. + + Args: + url: The request URL + params: Optional query parameters + + Returns: + Cached data dict or None if not found/expired + """ + cache_key = self._get_cache_key(url, params) + cache_path = self._get_cache_path(cache_key) + + if not cache_path.exists(): + return None + + try: + with open(cache_path, "r") as f: + cached = json.load(f) + + # Check if expired + if time.time() - cached.get("timestamp", 0) > self.ttl: + logger.debug(f"Cache expired for {url}") + cache_path.unlink(missing_ok=True) + return None + + logger.debug(f"Cache hit for {url}") + return cached.get("data") + + except (json.JSONDecodeError, KeyError) as e: + logger.warning(f"Invalid cache entry: {e}") + cache_path.unlink(missing_ok=True) + return None + + def set(self, url: str, data: Any, params: Optional[Dict] = None) -> None: + """ + Store a response in the cache. + + Args: + url: The request URL + data: Data to cache (must be JSON serializable) + params: Optional query parameters + """ + cache_key = self._get_cache_key(url, params) + cache_path = self._get_cache_path(cache_key) + + try: + with open(cache_path, "w") as f: + json.dump( + { + "timestamp": time.time(), + "url": url, + "data": data, + }, + f, + ) + logger.debug(f"Cached response for {url}") + except (TypeError, OSError) as e: + logger.warning(f"Failed to cache response: {e}") + + def clear(self) -> int: + """ + Clear all cached responses. + + Returns: + Number of cache entries cleared + """ + count = 0 + for cache_file in self.cache_dir.glob("*.json"): + try: + cache_file.unlink() + count += 1 + except OSError: + pass + logger.info(f"Cleared {count} cache entries") + return count + + def clear_expired(self) -> int: + """ + Clear only expired cache entries. + + Returns: + Number of expired entries cleared + """ + count = 0 + for cache_file in self.cache_dir.glob("*.json"): + try: + with open(cache_file, "r") as f: + cached = json.load(f) + if time.time() - cached.get("timestamp", 0) > self.ttl: + cache_file.unlink() + count += 1 + except (json.JSONDecodeError, OSError): + cache_file.unlink(missing_ok=True) + count += 1 + logger.debug(f"Cleared {count} expired cache entries") + return count + + +# Global cache instance +_response_cache: Optional[ResponseCache] = None + + +def get_response_cache(cache_dir: Optional[Path] = None) -> ResponseCache: + """Get or create the global response cache instance.""" + global _response_cache + if _response_cache is None: + _response_cache = ResponseCache(cache_dir) + return _response_cache + + +def cached_request( + url: str, + params: Optional[Dict] = None, + headers: Optional[Dict] = None, + timeout: int = DEFAULT_TIMEOUT, + use_cache: bool = True, + cache_ttl: int = DEFAULT_CACHE_TTL, +) -> Optional[Dict[str, Any]]: + """ + Make a GET request with caching and retry support. + + Args: + url: The URL to request + params: Optional query parameters + headers: Optional headers + timeout: Request timeout + use_cache: Whether to use caching + cache_ttl: Cache time-to-live in seconds + + Returns: + JSON response data or None on failure + """ + cache = get_response_cache() + + # Check cache first + if use_cache: + cached_data = cache.get(url, params) + if cached_data is not None: + return cached_data + + # Make request with retries + try: + response = make_request_with_retry( + url=url, + method="GET", + headers=headers, + params=params, + timeout=timeout, + ) + response.raise_for_status() + data = response.json() + + # Cache the response + if use_cache: + cache.set(url, data, params) + + return data + + except requests.exceptions.RequestException as e: + logger.error(f"Request failed: {e}") + return None + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON response: {e}") + return None diff --git a/quantcoder/tools/article_tools.py b/quantcoder/tools/article_tools.py index 58f296e..1223aa2 100644 --- a/quantcoder/tools/article_tools.py +++ b/quantcoder/tools/article_tools.py @@ -1,12 +1,15 @@ """Tools for article search, download, and processing.""" -import os import json import requests -import webbrowser from pathlib import Path from typing import Dict, List, Optional from .base import Tool, ToolResult +from ..core.http_utils import ( + make_request_with_retry, + cached_request, + DEFAULT_TIMEOUT, +) class SearchArticlesTool(Tool): @@ -60,21 +63,27 @@ def execute(self, query: str, max_results: int = 5) -> ToolResult: return ToolResult(success=False, error=str(e)) def _search_crossref(self, query: str, rows: int = 5) -> List[Dict]: - """Search CrossRef API for articles.""" + """Search CrossRef API for articles with retry and caching support.""" api_url = "https://api.crossref.org/works" params = { "query": query, "rows": rows, "select": "DOI,title,author,published-print,URL" } - headers = { - "User-Agent": "QuantCoder/2.0 (mailto:smr.laignel@gmail.com)" - } try: - response = requests.get(api_url, params=params, headers=headers, timeout=10) - response.raise_for_status() - data = response.json() + # Use cached_request for automatic retry and caching + data = cached_request( + url=api_url, + params=params, + timeout=DEFAULT_TIMEOUT, + use_cache=True, + cache_ttl=1800, # 30 minutes cache for search results + ) + + if not data: + self.logger.error("CrossRef API request failed after retries") + return [] articles = [] for item in data.get('message', {}).get('items', []): @@ -89,7 +98,7 @@ def _search_crossref(self, query: str, rows: int = 5) -> List[Dict]: return articles - except requests.exceptions.RequestException as e: + except Exception as e: self.logger.error(f"CrossRef API request failed: {e}") return [] @@ -187,13 +196,16 @@ def execute(self, article_id: int) -> ToolResult: return ToolResult(success=False, error=str(e)) def _download_pdf(self, url: str, save_path: Path, doi: Optional[str] = None) -> bool: - """Attempt to download PDF from URL.""" - headers = { - "User-Agent": "QuantCoder/2.0 (mailto:smr.laignel@gmail.com)" - } - + """Attempt to download PDF from URL with retry support.""" try: - response = requests.get(url, headers=headers, allow_redirects=True, timeout=30) + # Use make_request_with_retry for automatic retry on failure + response = make_request_with_retry( + url=url, + method="GET", + timeout=60, # Longer timeout for PDF downloads + retries=3, + backoff_factor=1.0, # 1s, 2s, 4s backoff + ) response.raise_for_status() if 'application/pdf' in response.headers.get('Content-Type', ''): @@ -202,7 +214,7 @@ def _download_pdf(self, url: str, save_path: Path, doi: Optional[str] = None) -> return True except requests.exceptions.RequestException as e: - self.logger.error(f"Failed to download PDF: {e}") + self.logger.error(f"Failed to download PDF after retries: {e}") return False diff --git a/requirements-lock.txt b/requirements-lock.txt new file mode 100644 index 0000000..489151d --- /dev/null +++ b/requirements-lock.txt @@ -0,0 +1,50 @@ +# QuantCoder CLI - Pinned Dependencies for Reproducible Builds +# Generated for Python 3.10+ +# Last updated: 2026-01-26 +# +# Usage: +# pip install -r requirements-lock.txt +# +# To update: +# pip install -e ".[dev]" +# pip freeze > requirements-lock.txt +# (then manually clean up to keep only direct dependencies) + +# Core CLI +click==8.1.7 +rich==13.7.1 +prompt-toolkit==3.0.43 +InquirerPy==0.3.4 +pygments==2.17.2 + +# HTTP & Networking +requests==2.31.0 +aiohttp==3.9.3 +urllib3==2.2.0 +certifi==2024.2.2 + +# PDF Processing +pdfplumber==0.10.4 +pdfminer.six==20231228 + +# NLP +spacy==3.7.4 + +# LLM Providers +openai==1.12.0 +anthropic==0.18.1 +mistralai==0.1.8 + +# Configuration +python-dotenv==1.0.1 +toml==0.10.2 + +# Development dependencies (install with: pip install -e ".[dev]") +# pytest==7.4.4 +# pytest-cov==4.1.0 +# pytest-mock==3.12.0 +# black==24.1.1 +# ruff==0.2.0 +# mypy==1.8.0 +# pre-commit==3.6.0 +# pip-audit==2.7.0