From 5f884f24268bdb854338f64bf600fd76b45cc38e Mon Sep 17 00:00:00 2001 From: Emmzyemms Date: Mon, 27 Jul 2026 13:48:16 +0100 Subject: [PATCH] Optimize Database Connection Pooling --- .github/workflows/llm-code-review.yml | 212 +++++++ .github/workflows/llm-docs.yml | 304 ++++++++++ astroml/features/PARALLEL_COMPUTATION.md | 298 ++++++++++ astroml/features/feature_store.py | 253 ++++++-- astroml/llm/code_review/__init__.py | 29 + astroml/llm/code_review/analyzers/__init__.py | 14 + .../code_review/analyzers/python_analyzer.py | 295 ++++++++++ .../llm/code_review/analyzers/sql_analyzer.py | 173 ++++++ .../code_review/analyzers/yaml_analyzer.py | 164 ++++++ astroml/llm/code_review/checks/__init__.py | 30 + astroml/llm/code_review/checks/complexity.py | 215 +++++++ astroml/llm/code_review/checks/correctness.py | 105 ++++ .../llm/code_review/checks/documentation.py | 122 ++++ astroml/llm/code_review/checks/performance.py | 105 ++++ astroml/llm/code_review/checks/security.py | 123 ++++ astroml/llm/code_review/checks/style.py | 104 ++++ astroml/llm/code_review/checks/testing.py | 98 ++++ astroml/llm/code_review/reviewer.py | 445 ++++++++++++++ astroml/llm/code_review/suggestions.py | 129 +++++ astroml/llm/docs/__init__.py | 31 + astroml/llm/docs/code_analyzer.py | 499 ++++++++++++++++ astroml/llm/docs/generator.py | 545 ++++++++++++++++++ astroml/llm/docs/updater.py | 405 +++++++++++++ astroml/llm/docs/validator.py | 453 +++++++++++++++ astroml/llm/docs/writers.py | 468 +++++++++++++++ benchmark_parallel_features.py | 182 ++++++ profile_feature_computation.py | 72 +++ tests/features/test_feature_store.py | 321 ++++++++++- tools/doc_generator/__init__.py | 3 + tools/doc_generator/cli.py | 232 ++++++++ 30 files changed, 6386 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/llm-code-review.yml create mode 100644 .github/workflows/llm-docs.yml create mode 100644 astroml/features/PARALLEL_COMPUTATION.md create mode 100644 astroml/llm/code_review/__init__.py create mode 100644 astroml/llm/code_review/analyzers/__init__.py create mode 100644 astroml/llm/code_review/analyzers/python_analyzer.py create mode 100644 astroml/llm/code_review/analyzers/sql_analyzer.py create mode 100644 astroml/llm/code_review/analyzers/yaml_analyzer.py create mode 100644 astroml/llm/code_review/checks/__init__.py create mode 100644 astroml/llm/code_review/checks/complexity.py create mode 100644 astroml/llm/code_review/checks/correctness.py create mode 100644 astroml/llm/code_review/checks/documentation.py create mode 100644 astroml/llm/code_review/checks/performance.py create mode 100644 astroml/llm/code_review/checks/security.py create mode 100644 astroml/llm/code_review/checks/style.py create mode 100644 astroml/llm/code_review/checks/testing.py create mode 100644 astroml/llm/code_review/reviewer.py create mode 100644 astroml/llm/code_review/suggestions.py create mode 100644 astroml/llm/docs/__init__.py create mode 100644 astroml/llm/docs/code_analyzer.py create mode 100644 astroml/llm/docs/generator.py create mode 100644 astroml/llm/docs/updater.py create mode 100644 astroml/llm/docs/validator.py create mode 100644 astroml/llm/docs/writers.py create mode 100644 benchmark_parallel_features.py create mode 100644 profile_feature_computation.py create mode 100644 tools/doc_generator/__init__.py create mode 100644 tools/doc_generator/cli.py diff --git a/.github/workflows/llm-code-review.yml b/.github/workflows/llm-code-review.yml new file mode 100644 index 0000000..ac2ba2f --- /dev/null +++ b/.github/workflows/llm-code-review.yml @@ -0,0 +1,212 @@ +name: LLM Code Review + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.py' + - '**.sql' + - '**.yaml' + - '**.yml' + +jobs: + code-review: + name: AI Code Review + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - 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 . + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v44 + with: + files: | + **.py + **.sql + **.yaml + **.yml + + - name: Run code review + id: review + if: steps.changed-files.outputs.any_changed == 'true' + run: | + python - << 'EOF' + import os + import sys + from pathlib import Path + + # Add astroml to path + sys.path.insert(0, '/home/runner/work/astroml/astroml') + + from astroml.llm.code_review import CodeReviewer + + # Initialize reviewer + reviewer = CodeReviewer( + enable_llm=False, + max_review_time=120, + ignored_rules=set() + ) + + # Get changed files + changed_files = os.getenv('CHANGED_FILES', '').split() + + if not changed_files: + print("No changed files to review") + sys.exit(0) + + print(f"Reviewing {len(changed_files)} file(s)...") + + all_suggestions = [] + files_reviewed = 0 + + for file_path in changed_files: + if not os.path.exists(file_path): + continue + + print(f"Reviewing: {file_path}") + result = reviewer.review_file(file_path) + + if result.status.value == 'completed': + all_suggestions.extend(result.suggestions) + files_reviewed += 1 + else: + print(f"Error reviewing {file_path}: {result.error}") + + # Generate review comment + if all_suggestions: + print("## šŸ¤– AI Code Review Results") + print(f"\nReviewed {files_reviewed} file(s)") + print(f"Found {len(all_suggestions)} issue(s):\n") + + # Group by category + from astroml.llm.code_review.suggestions import SuggestionCategory + from collections import defaultdict + + grouped = defaultdict(list) + for suggestion in all_suggestions: + grouped[suggestion.category].append(suggestion) + + for category in [SuggestionCategory.SECURITY, SuggestionCategory.PERFORMANCE, + SuggestionCategory.CORRECTNESS, SuggestionCategory.STYLE, + SuggestionCategory.TESTING, SuggestionCategory.DOCUMENTATION, + SuggestionCategory.COMPLEXITY]: + if category in grouped and grouped[category]: + print(f"## {category.value}") + for suggestion in grouped[category]: + print(suggestion.format_markdown()) + print("") + else: + print("## šŸ¤– AI Code Review Results") + print("\nNo issues found! šŸŽ‰") + + # Save suggestions for GitHub comment + with open('/tmp/review_output.txt', 'w') as f: + if all_suggestions: + f.write("## šŸ¤– AI Code Review Results\n\n") + f.write(f"Reviewed {files_reviewed} file(s)\n") + f.write(f"Found {len(all_suggestions)} issue(s):\n\n") + + grouped = defaultdict(list) + for suggestion in all_suggestions: + grouped[suggestion.category].append(suggestion) + + for category in [SuggestionCategory.SECURITY, SuggestionCategory.PERFORMANCE, + SuggestionCategory.CORRECTNESS, SuggestionCategory.STYLE, + SuggestionCategory.TESTING, SuggestionCategory.DOCUMENTATION, + SuggestionCategory.COMPLEXITY]: + if category in grouped and grouped[category]: + f.write(f"## {category.value}\n") + for suggestion in grouped[category]: + f.write(suggestion.format_markdown() + "\n") + f.write("\n") + else: + f.write("## šŸ¤– AI Code Review Results\n\n") + f.write("No issues found! šŸŽ‰\n") + + EOF + env: + CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} + + - name: Post review comment + if: steps.review.outcome == 'success' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + let reviewComment = ''; + + try { + reviewComment = fs.readFileSync('/tmp/review_output.txt', 'utf8'); + } catch (error) { + console.log('No review output found'); + return; + } + + // Find existing review comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('šŸ¤– AI Code Review Results') + ); + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: reviewComment + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: reviewComment + }); + } + + - name: Check for critical issues + if: steps.review.outcome == 'success' + run: | + python - << 'EOF' + import sys + sys.path.insert(0, '/home/runner/work/astroml/astroml') + + from astroml.llm.code_review.suggestions import SuggestionSeverity + + # Read review output + with open('/tmp/review_output.txt', 'r') as f: + content = f.read() + + # Check for HIGH severity security issues + if 'HIGH' in content and 'Security' in content: + print("āš ļø Critical security issues found!") + print("Please review and address before merging.") + sys.exit(1) + else: + print("No critical security issues detected.") + sys.exit(0) + + EOF diff --git a/.github/workflows/llm-docs.yml b/.github/workflows/llm-docs.yml new file mode 100644 index 0000000..a471e2a --- /dev/null +++ b/.github/workflows/llm-docs.yml @@ -0,0 +1,304 @@ +name: LLM Documentation Generator + +on: + push: + branches: [main, develop] + paths: + - 'astroml/**/*.py' + - 'astroml/**/*.sql' + - 'astroml/**/*.yaml' + - 'astroml/**/*.yml' + pull_request: + branches: [main, develop] + paths: + - 'astroml/**/*.py' + - 'astroml/**/*.sql' + - 'astroml/**/*.yaml' + - 'astroml/**/*.yml' + workflow_dispatch: + inputs: + doc_type: + description: 'Type of documentation to generate' + required: false + default: 'code' + type: choice + options: + - code + - api + - architecture + - tutorial + format: + description: 'Output format' + required: false + default: 'markdown' + type: choice + options: + - markdown + - rst + - html + +jobs: + generate-docs: + name: Generate Documentation + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - 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 . + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v44 + with: + files: | + astroml/**/*.py + astroml/**/*.sql + astroml/**/*.yaml + astroml/**/*.yml + + - name: Generate code documentation + if: steps.changed-files.outputs.any_changed == 'true' + run: | + python - << 'EOF' + import sys + sys.path.insert(0, '/home/runner/work/astroml/astroml') + + from astroml.llm.docs.generator import DocumentationGenerator, GenerationConfig, DocType, OutputFormat + from pathlib import Path + + # Get doc type from input or default + doc_type_str = '${{ github.event.inputs.doc_type }}' or 'code' + format_str = '${{ github.event.inputs.format }}' or 'markdown' + + config = GenerationConfig( + doc_type=DocType(doc_type_str), + output_format=OutputFormat(format_str), + output_dir='generated_docs', + include_private=False, + include_internal=False, + include_examples=True, + include_type_hints=True, + validate_after_generation=True, + ) + + generator = DocumentationGenerator(config) + + # Generate documentation for astroml package + result = generator.generate_from_directory( + source_dir='/home/runner/work/astroml/astroml/astroml', + output_dir='generated_docs' + ) + + if result.success: + print(f"āœ“ Documentation generated successfully") + print(f" Files generated: {len(result.files_generated)}") + for file in result.files_generated: + print(f" - {file}") + print(f" Duration: {result.duration_seconds:.2f}s") + + if result.validation_result: + print(f"\nValidation Results:") + print(f" Valid: {result.validation_result.is_valid}") + print(f" Completeness Score: {result.validation_result.completeness_score:.1f}/100") + print(f" Readability Score: {result.validation_result.readability_score:.1f}/100") + print(f" Issues: {len(result.validation_result.issues)}") + + # Exit with error if validation fails + if not result.validation_result.is_valid: + sys.exit(1) + else: + print(f"āœ— Documentation generation failed") + print(f" Error: {result.error}") + sys.exit(1) + + EOF + + - name: Generate API documentation + if: steps.changed-files.outputs.any_changed == 'true' + run: | + python - << 'EOF' + import sys + sys.path.insert(0, '/home/runner/work/astroml/astroml') + + from astroml.llm.docs.generator import DocumentationGenerator, GenerationConfig + from pathlib import Path + + config = GenerationConfig( + output_dir='generated_docs', + validate_after_generation=True, + ) + + generator = DocumentationGenerator(config) + + # Find API files + api_dir = Path('/home/runner/work/astroml/astroml/astroml/api') + if api_dir.exists(): + for api_file in api_dir.rglob('*.py'): + if 'route' in api_file.name or 'endpoint' in api_file.name or api_file.name == 'main.py': + print(f"Generating API docs for {api_file}") + result = generator.generate_api_docs(str(api_file)) + if result.success: + print(f" āœ“ Generated: {result.files_generated}") + else: + print(f" āœ— Failed: {result.error}") + + EOF + + - name: Upload generated documentation + if: steps.changed-files.outputs.any_changed == 'true' + uses: actions/upload-artifact@v4 + with: + name: generated-docs + path: generated_docs/ + retention-days: 7 + + - name: Check for outdated documentation + run: | + python - << 'EOF' + import sys + sys.path.insert(0, '/home/runner/work/astroml/astroml') + + from astroml.llm.docs.updater import DocumentationUpdater + + updater = DocumentationUpdater(metadata_dir='.doc_metadata') + + # Check existing docs directory + docs_dir = Path('/home/runner/work/astroml/astroml/docs') + if docs_dir.exists(): + outdated = updater.detect_outdated_docs(str(docs_dir)) + + if outdated: + print(f"āš ļø Found {len(outdated)} outdated documentation file(s):") + for doc in outdated: + print(f" - {doc}") + print("\nConsider running documentation update to sync with code changes.") + else: + print("āœ“ Documentation is up to date") + else: + print("No existing docs directory found") + + EOF + + - name: Create PR comment with results + if: github.event_name == 'pull_request' && steps.changed-files.outputs.any_changed == 'true' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + let comment = "## šŸ“š Documentation Generation Results\n\n"; + + // Check if docs were generated + if (fs.existsSync('generated_docs')) { + comment += "āœ… Documentation has been generated for the changed files.\n\n"; + comment += "The generated documentation has been uploaded as an artifact.\n\n"; + comment += "You can download and review the generated documentation from the workflow artifacts.\n"; + } else { + comment += "ā„¹ļø No documentation was generated (no relevant code changes).\n"; + } + + // Find existing comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('šŸ“š Documentation Generation Results') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: comment + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + } + + validate-existing-docs: + name: Validate Existing Documentation + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Checkout code + 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 . + + - name: Validate documentation + run: | + python - << 'EOF' + import sys + sys.path.insert(0, '/home/runner/work/astroml/astroml') + + from astroml.llm.docs.validator import DocumentationValidator + from pathlib import Path + + validator = DocumentationValidator() + + docs_dir = Path('/home/runner/work/astroml/astroml/docs') + if not docs_dir.exists(): + print("No docs directory found") + sys.exit(0) + + print(f"Validating documentation in {docs_dir}...") + + total_issues = 0 + total_files = 0 + + for doc_file in docs_dir.rglob('*.md'): + total_files += 1 + result = validator.validate_documentation(str(doc_file)) + + print(f"\n{doc_file.relative_to(docs_dir)}:") + print(f" Valid: {result.is_valid}") + print(f" Completeness: {result.completeness_score:.1f}/100") + print(f" Readability: {result.readability_score:.1f}/100") + + if result.issues: + total_issues += len(result.issues) + print(f" Issues: {len(result.issues)}") + for issue in result.issues[:3]: # Show first 3 issues + print(f" - [{issue.severity.value}] {issue.message}") + + print(f"\n{'='*50}") + print(f"Total files validated: {total_files}") + print(f"Total issues found: {total_issues}") + + if total_issues > 10: + print("\nāš ļø High number of issues found. Consider improving documentation quality.") + sys.exit(1) + + EOF diff --git a/astroml/features/PARALLEL_COMPUTATION.md b/astroml/features/PARALLEL_COMPUTATION.md new file mode 100644 index 0000000..354d286 --- /dev/null +++ b/astroml/features/PARALLEL_COMPUTATION.md @@ -0,0 +1,298 @@ +# Parallel Feature Computation + +The Feature Store now supports parallel computation of features to improve performance on multi-core systems. + +## Overview + +Feature computations can be run in parallel using `concurrent.futures.ThreadPoolExecutor`. This is particularly beneficial for: +- Large datasets with many entities +- Computationally intensive features (e.g., graph centrality measures) +- Batch feature computation for multiple features + +## Configuration + +### Constructor Parameters + +```python +from astroml.features import FeatureStore + +store = FeatureStore( + storage_path="./feature_store", + max_workers=4, # Maximum number of parallel workers (default: 4) + chunk_size=100, # Entities per chunk (default: 100) + enable_parallel=True, # Enable parallel computation (default: True) +) +``` + +### Configuration via YAML + +Create or update `config/feature_store.yaml`: + +```yaml +cache: + max_size_mb: 500 + ttl_seconds: 900 + maxsize: 128 + +parallel: + max_workers: 4 + chunk_size: 100 + enable: true +``` + +### Convenience Function + +```python +from astroml.features import create_feature_store + +store = create_feature_store( + storage_path="./feature_store", + max_workers=8, + chunk_size=50, + enable_parallel=True, +) +``` + +## Parameters + +### max_workers + +- **Type**: `int` +- **Default**: `4` +- **Description**: Maximum number of parallel workers for feature computation +- **Recommendations**: + - Set to `1` to disable parallelism + - Set to `2-4` for I/O-bound operations + - Set to `4-8` for CPU-bound operations + - Do not exceed the number of CPU cores + +### chunk_size + +- **Type**: `int` +- **Default**: `100` +- **Description**: Number of entities to process per chunk in parallel computation +- **Trade-offs**: + - **Smaller chunks**: More parallelism, lower memory per chunk, higher overhead + - **Larger chunks**: Less overhead, higher memory per chunk, less parallelism +- **Recommendations**: + - Use smaller chunks (50-100) for memory-intensive features + - Use larger chunks (200-500) for simple aggregations + - Adjust based on available memory + +### enable_parallel + +- **Type**: `bool` +- **Default**: `True` +- **Description**: Whether to enable parallel feature computation +- **Behavior**: + - When `True` and `max_workers > 1`: Uses parallel computation for large datasets + - When `False` or `max_workers == 1`: Always uses sequential computation + - Parallel computation is only used when data size exceeds `chunk_size` + +## Behavior + +### Automatic Parallelization + +Parallel computation is automatically triggered when: +1. `enable_parallel` is `True` +2. `max_workers > 1` +3. Data size exceeds `chunk_size` + +For small datasets (below `chunk_size`), sequential computation is used to avoid overhead. + +### Chunking Strategy + +Data is split into chunks based on unique entities: +1. Extract unique entity IDs +2. Split entities into chunks of size `chunk_size` +3. Process chunks in parallel using `ThreadPoolExecutor` +4. Combine results from all chunks + +### Fallback to Sequential + +If parallel computation fails, the system automatically falls back to sequential computation with a warning. This ensures robustness even if: +- Thread pool initialization fails +- Chunk processing encounters unexpected errors +- Resource constraints prevent parallel execution + +### Thread Safety + +The implementation ensures thread safety: +- Cache operations are protected by `threading.Lock` +- Each chunk processes independent data +- No shared mutable state between workers +- Results are combined after all chunks complete + +## Usage Examples + +### Basic Parallel Computation + +```python +from astroml.features import FeatureStore +import pandas as pd + +# Create store with parallel computation enabled +store = FeatureStore( + storage_path="./feature_store", + max_workers=4, + chunk_size=100, + enable_parallel=True, +) + +# Large dataset will be processed in parallel +large_data = pd.DataFrame({ + 'entity_id': [...], # 10000+ entities + 'timestamp': [...], + 'amount': [...], +}) + +result = store.compute_feature( + feature_name="daily_transaction_count", + data=large_data, + entity_col="entity_id", + timestamp_col="timestamp", +) +``` + +### Parallel Feature Fetching + +```python +# Fetch multiple features in parallel +features = store.get_features_for_entities( + feature_names=["feature1", "feature2", "feature3"], + entity_ids=["entity1", "entity2", "entity3"], + parallel=True, # Enable parallel fetching +) +``` + +### Disable Parallelism + +```python +# Disable parallel computation +store = FeatureStore( + storage_path="./feature_store", + max_workers=1, + enable_parallel=False, +) + +# Or disable per-call +features = store.get_features_for_entities( + feature_names=["feature1", "feature2"], + entity_ids=["entity1", "entity2"], + parallel=False, +) +``` + +## Performance Considerations + +### When to Use Parallelism + +**Use parallelism when:** +- Dataset has > 1000 entities +- Features are computationally expensive (graph metrics, complex aggregations) +- Multiple features need to be computed +- System has multiple CPU cores available + +**Avoid parallelism when:** +- Dataset is small (< 100 entities) +- Features are simple and fast +- Memory is constrained +- Running on single-core systems + +### Expected Speedup + +Speedup depends on: +- Number of workers +- Dataset size +- Feature complexity +- I/O vs CPU bound operations + +Typical speedup ranges: +- **2 workers**: 1.5-1.8x +- **4 workers**: 2.5-3.5x +- **8 workers**: 3.0-5.0x + +Efficiency typically decreases beyond 8 workers due to overhead. + +### Memory Usage + +Parallel computation increases memory usage: +- Each chunk holds a copy of the data +- Memory usage ā‰ˆ `chunk_size * row_size * max_workers` +- Monitor memory usage and adjust `chunk_size` if needed + +## Benchmarking + +A benchmark script is provided to measure speedup: + +```bash +python benchmark_parallel_features.py +``` + +This script tests different: +- Data sizes (1K, 5K, 10K, 50K rows) +- Worker configurations (1, 2, 4, 8 workers) +- Feature types + +## Troubleshooting + +### Parallel Computation Not Triggered + +If parallel computation is not being used: +1. Check `enable_parallel` is `True` +2. Check `max_workers > 1` +3. Verify data size exceeds `chunk_size` +4. Check logs for "Parallel computation failed" warnings + +### Out of Memory Errors + +If you encounter memory errors: +1. Reduce `chunk_size` (e.g., from 100 to 50) +2. Reduce `max_workers` (e.g., from 8 to 4) +3. Process data in smaller batches +4. Monitor memory usage during computation + +### No Performance Improvement + +If parallel computation doesn't improve performance: +1. Feature may be I/O-bound (limited by disk/network) +2. Overhead of chunking may exceed benefit for small datasets +3. Consider using `ProcessPoolExecutor` for CPU-bound features +4. Profile to identify bottlenecks + +## Implementation Details + +### ThreadPoolExecutor vs ProcessPoolExecutor + +The current implementation uses `ThreadPoolExecutor` because: +- Pandas operations release the GIL for many operations +- Lower memory overhead compared to processes +- Easier to share data between workers + +For CPU-bound features that don't release the GIL, consider: +- Using `ProcessPoolExecutor` (requires pickling data) +- Optimizing the feature computation itself +- Using vectorized operations + +### Cache Consistency + +The cache remains consistent during parallel computation: +- Cache writes are protected by locks +- Each chunk computes independently +- Cache invalidation happens after all chunks complete +- No race conditions in cache operations + +## Testing + +Unit tests for parallel computation are in `tests/features/test_feature_store.py`: + +```bash +pytest tests/features/test_feature_store.py::TestParallelFeatureComputation -v +``` + +Tests cover: +- Configuration options +- Parallel vs sequential execution +- Fallback behavior +- Thread safety +- Chunking behavior diff --git a/astroml/features/feature_store.py b/astroml/features/feature_store.py index 85eaa91..69b03a8 100644 --- a/astroml/features/feature_store.py +++ b/astroml/features/feature_store.py @@ -15,7 +15,6 @@ from __future__ import annotations -import hashlib import json import logging import threading @@ -26,7 +25,6 @@ Dict, List, Optional, - Set, Union, Callable, Protocol, @@ -34,16 +32,14 @@ ) from enum import Enum from pathlib import Path -import pickle import sqlite3 from contextlib import contextmanager +import concurrent.futures import pandas as pd -import numpy as np -from cachetools import TTLCache, LRUCache +from cachetools import TTLCache from astroml.features.schema_validation import ( - validate_dataframe, dry_run_ingestion, ValidationResult, FEATURE_VALUE_SCHEMA, @@ -553,8 +549,6 @@ def _register_builtin_features(self) -> None: structural_importance, node_features, asset_diversity, - imbalance, - memo, ) # Register frequency features @@ -703,6 +697,8 @@ class FeatureStore: # or constructor arguments). _DEFAULT_MAXSIZE: int = 128 _DEFAULT_TTL: int = 900 # 15 minutes + _DEFAULT_MAX_WORKERS: int = 4 + _DEFAULT_CHUNK_SIZE: int = 100 def __init__( self, @@ -710,6 +706,9 @@ def __init__( max_cache_size_mb: int = 500, cache_ttl_seconds: int = _DEFAULT_TTL, cache_maxsize: int = _DEFAULT_MAXSIZE, + max_workers: int = _DEFAULT_MAX_WORKERS, + chunk_size: int = _DEFAULT_CHUNK_SIZE, + enable_parallel: bool = True, ): """Initialize feature store. @@ -722,6 +721,12 @@ def __init__( (default: 900 = 15 min, matching ``config/feature_store.yaml``). cache_maxsize: Maximum number of entries in the TTLCache before LRU eviction kicks in (default: 128). + max_workers: Maximum number of parallel workers for feature computation + (default: 4). Set to 1 to disable parallelism. + chunk_size: Number of entities to process per chunk in parallel computation + (default: 100). Larger chunks reduce overhead but may increase memory usage. + enable_parallel: Whether to enable parallel feature computation + (default: True). """ self.storage = FeatureStorage(storage_path) self.registry = FeatureRegistry(self.storage) @@ -749,6 +754,11 @@ def __init__( self._cache_hits: int = 0 self._cache_misses: int = 0 self._cache_evictions: int = 0 + + # Parallel computation settings + self._max_workers: int = max_workers + self._chunk_size: int = chunk_size + self._enable_parallel: bool = enable_parallel and max_workers > 1 def register_feature( self, @@ -800,43 +810,160 @@ def compute_feature( **kwargs: Any, ) -> pd.DataFrame: """Compute feature values. - + Args: feature_name: Name of feature to compute data: Input data entity_col: Entity identifier column timestamp_col: Timestamp column **kwargs: Additional parameters - + Returns: DataFrame with computed feature values """ computer = self.registry.get_computer(feature_name) if computer is None: raise ValueError(f"Feature '{feature_name}' not found") - + logger.info(f"Computing feature: {feature_name}") - + # Validate input data required_cols = [entity_col, timestamp_col] missing_cols = [col for col in required_cols if col not in data.columns] if missing_cols: raise ValueError(f"Missing required columns: {missing_cols}") - - # Compute feature + + # Compute feature with parallelism if enabled and data is large enough + if self._enable_parallel and len(data) > self._chunk_size: + try: + result = self._compute_feature_parallel( + computer, feature_name, data, entity_col, timestamp_col, **kwargs + ) + except Exception as e: + logger.warning(f"Parallel computation failed, falling back to sequential: {e}") + result = self._compute_feature_sequential( + computer, feature_name, data, entity_col, timestamp_col, **kwargs + ) + else: + result = self._compute_feature_sequential( + computer, feature_name, data, entity_col, timestamp_col, **kwargs + ) + + # Ensure result is indexed by entity + if entity_col in result.columns: + result = result.set_index(entity_col) + + logger.info(f"Computed {len(result)} feature values for {feature_name}") + return result + + def _compute_feature_sequential( + self, + computer: FeatureComputer, + feature_name: str, + data: pd.DataFrame, + entity_col: str, + timestamp_col: str, + **kwargs: Any, + ) -> pd.DataFrame: + """Compute feature values sequentially. + + Args: + computer: Feature computation function + feature_name: Name of feature to compute + data: Input data + entity_col: Entity identifier column + timestamp_col: Timestamp column + **kwargs: Additional parameters + + Returns: + DataFrame with computed feature values + """ try: result = computer(data, entity_col, timestamp_col, **kwargs) - - # Ensure result is indexed by entity - if entity_col in result.columns: - result = result.set_index(entity_col) - - logger.info(f"Computed {len(result)} feature values for {feature_name}") return result - except Exception as e: logger.error(f"Error computing feature {feature_name}: {e}") raise + + def _compute_feature_parallel( + self, + computer: FeatureComputer, + feature_name: str, + data: pd.DataFrame, + entity_col: str, + timestamp_col: str, + **kwargs: Any, + ) -> pd.DataFrame: + """Compute feature values in parallel using chunking. + + Splits the input data into chunks and processes them in parallel + using ThreadPoolExecutor. Results are combined after all chunks complete. + + Args: + computer: Feature computation function + feature_name: Name of feature to compute + data: Input data + entity_col: Entity identifier column + timestamp_col: Timestamp column + **kwargs: Additional parameters + + Returns: + DataFrame with computed feature values from all chunks combined + + Raises: + Exception: If parallel computation fails + """ + # Split data into chunks by entity + unique_entities = data[entity_col].unique() + chunks = [] + for i in range(0, len(unique_entities), self._chunk_size): + chunk_entities = unique_entities[i : i + self._chunk_size] + chunk_data = data[data[entity_col].isin(chunk_entities)].copy() + chunks.append(chunk_data) + + logger.info( + f"Processing {len(data)} rows in {len(chunks)} chunks " + f"with {self._max_workers} workers" + ) + + # Process chunks in parallel + def process_chunk(chunk: pd.DataFrame) -> pd.DataFrame: + """Process a single chunk of data.""" + try: + result = computer(chunk, entity_col, timestamp_col, **kwargs) + return result + except Exception as e: + logger.error(f"Error processing chunk: {e}") + raise + + results = [] + try: + with concurrent.futures.ThreadPoolExecutor( + max_workers=self._max_workers + ) as executor: + future_to_chunk = { + executor.submit(process_chunk, chunk): chunk + for chunk in chunks + } + + for future in concurrent.futures.as_completed(future_to_chunk): + try: + chunk_result = future.result() + results.append(chunk_result) + except Exception as e: + logger.error(f"Chunk processing failed: {e}") + raise + + except Exception as e: + logger.error(f"Parallel computation failed: {e}") + raise + + # Combine results from all chunks + if results: + combined_result = pd.concat(results, axis=0) + return combined_result + else: + return pd.DataFrame() def store_feature( self, @@ -1098,33 +1225,74 @@ def get_features_for_entities( feature_names: List[str], entity_ids: List[str], timestamp: Optional[datetime] = None, + parallel: bool = True, ) -> pd.DataFrame: """Get multiple features for specific entities. - + Args: feature_names: List of feature names entity_ids: List of entity IDs timestamp: Optional timestamp for point-in-time queries - + parallel: Whether to fetch features in parallel + Returns: DataFrame with features indexed by entity """ feature_data = {} - - for feature_name in feature_names: - values = self.get_feature(feature_name, entity_ids, timestamp) - if values is not None: - # Extract the feature column (assuming single column features) - if len(values.columns) == 1: - feature_data[feature_name] = values.iloc[:, 0] - else: - # Multi-column features - prefix column names - for col in values.columns: - feature_data[f"{feature_name}_{col}"] = values[col] - + + if parallel and self._enable_parallel and len(feature_names) > 1: + # Fetch features in parallel + def fetch_feature(feature_name: str) -> tuple[str, Optional[pd.DataFrame]]: + """Fetch a single feature.""" + values = self.get_feature(feature_name, entity_ids, timestamp) + return feature_name, values + + try: + with concurrent.futures.ThreadPoolExecutor( + max_workers=min(self._max_workers, len(feature_names)) + ) as executor: + future_to_feature = { + executor.submit(fetch_feature, fn): fn + for fn in feature_names + } + + for future in concurrent.futures.as_completed(future_to_feature): + feature_name = future_to_feature[future] + try: + fn, values = future.result() + if values is not None: + if len(values.columns) == 1: + feature_data[feature_name] = values.iloc[:, 0] + else: + for col in values.columns: + feature_data[f"{feature_name}_{col}"] = values[col] + except Exception as e: + logger.error(f"Failed to fetch feature {feature_name}: {e}") + except Exception as e: + logger.warning(f"Parallel fetch failed, falling back to sequential: {e}") + # Fallback to sequential + for feature_name in feature_names: + values = self.get_feature(feature_name, entity_ids, timestamp) + if values is not None: + if len(values.columns) == 1: + feature_data[feature_name] = values.iloc[:, 0] + else: + for col in values.columns: + feature_data[f"{feature_name}_{col}"] = values[col] + else: + # Sequential fetch + for feature_name in feature_names: + values = self.get_feature(feature_name, entity_ids, timestamp) + if values is not None: + if len(values.columns) == 1: + feature_data[feature_name] = values.iloc[:, 0] + else: + for col in values.columns: + feature_data[f"{feature_name}_{col}"] = values[col] + if not feature_data: return pd.DataFrame() - + result = pd.DataFrame(feature_data, index=entity_ids) return result @@ -1258,6 +1426,9 @@ def _load_feature_store_config(config_path: Optional[Union[str, Path]] = None) - def create_feature_store( storage_path: str = "./feature_store", config_path: Optional[Union[str, Path]] = None, + max_workers: Optional[int] = None, + chunk_size: Optional[int] = None, + enable_parallel: Optional[bool] = None, ) -> FeatureStore: """Create a :class:`FeatureStore` instance, optionally driven by YAML config. @@ -1267,18 +1438,28 @@ def create_feature_store( Args: storage_path: Path to feature store storage. config_path: Override for the YAML config file location. + max_workers: Maximum number of parallel workers for feature computation. + If not provided, reads from config or uses default (4). + chunk_size: Number of entities to process per chunk in parallel computation. + If not provided, reads from config or uses default (100). + enable_parallel: Whether to enable parallel feature computation. + If not provided, reads from config or uses default (True). Returns: Configured :class:`FeatureStore` instance. """ cfg = _load_feature_store_config(config_path) cache_cfg = cfg.get("cache", {}) + parallel_cfg = cfg.get("parallel", {}) return FeatureStore( storage_path=storage_path, max_cache_size_mb=cache_cfg.get("max_size_mb", 500), cache_ttl_seconds=cache_cfg.get("ttl_seconds", FeatureStore._DEFAULT_TTL), cache_maxsize=cache_cfg.get("maxsize", FeatureStore._DEFAULT_MAXSIZE), + max_workers=max_workers or parallel_cfg.get("max_workers", FeatureStore._DEFAULT_MAX_WORKERS), + chunk_size=chunk_size or parallel_cfg.get("chunk_size", FeatureStore._DEFAULT_CHUNK_SIZE), + enable_parallel=enable_parallel if enable_parallel is not None else parallel_cfg.get("enable", True), ) diff --git a/astroml/llm/code_review/__init__.py b/astroml/llm/code_review/__init__.py new file mode 100644 index 0000000..2deede9 --- /dev/null +++ b/astroml/llm/code_review/__init__.py @@ -0,0 +1,29 @@ +""" +LLM-powered code review system for astroml. + +This module provides intelligent code review capabilities including: +- Security vulnerability detection +- Performance analysis +- Style and best practices checking +- Correctness verification +- Testing coverage analysis +- Documentation completeness +- Complexity assessment +""" + +from astroml.llm.code_review.reviewer import CodeReviewer, ReviewResult +from astroml.llm.code_review.suggestions import ( + Suggestion, + SuggestionCategory, + SuggestionSeverity, +) + +__all__ = [ + "CodeReviewer", + "ReviewResult", + "Suggestion", + "SuggestionCategory", + "SuggestionSeverity", +] + +__version__ = "0.1.0" diff --git a/astroml/llm/code_review/analyzers/__init__.py b/astroml/llm/code_review/analyzers/__init__.py new file mode 100644 index 0000000..ac1c3a6 --- /dev/null +++ b/astroml/llm/code_review/analyzers/__init__.py @@ -0,0 +1,14 @@ +""" +Language-specific analyzers for code review. + +This package contains analyzers for different programming languages: +- Python analyzer +- SQL analyzer +- YAML analyzer +""" + +from astroml.llm.code_review.analyzers.python_analyzer import PythonAnalyzer +from astroml.llm.code_review.analyzers.sql_analyzer import SQLAnalyzer +from astroml.llm.code_review.analyzers.yaml_analyzer import YAMLAnalyzer + +__all__ = ["PythonAnalyzer", "SQLAnalyzer", "YAMLAnalyzer"] diff --git a/astroml/llm/code_review/analyzers/python_analyzer.py b/astroml/llm/code_review/analyzers/python_analyzer.py new file mode 100644 index 0000000..9682aba --- /dev/null +++ b/astroml/llm/code_review/analyzers/python_analyzer.py @@ -0,0 +1,295 @@ +""" +Python-specific code analyzer. + +This module provides analysis capabilities for Python code, +including AST parsing and pattern matching for common issues. +""" + +import ast +import re +from typing import List, Optional, Dict, Any +from dataclasses import dataclass + +from astroml.llm.code_review.suggestions import ( + Suggestion, + SuggestionCategory, + SuggestionSeverity, +) + + +@dataclass +class CodeContext: + """Context information for code analysis.""" + + file_path: str + content: str + line_offset: int = 0 + + +class PythonAnalyzer: + """ + Analyzer for Python code. + + Performs static analysis on Python code to identify potential issues + related to security, performance, style, and correctness. + """ + + def __init__(self): + """Initialize the Python analyzer.""" + self.security_patterns = self._init_security_patterns() + self.performance_patterns = self._init_performance_patterns() + + def _init_security_patterns(self) -> Dict[str, Dict[str, Any]]: + """Initialize security vulnerability patterns.""" + return { + "sql_injection": { + "pattern": re.compile( + r'(execute|executemany)\s*\(\s*[f"\'].*\{.*\}.*[f"\']\s*\)', + re.IGNORECASE, + ), + "severity": SuggestionSeverity.HIGH, + "message": "SQL injection risk - use parameterized queries", + "suggested_fix": "Use parameterized queries with ? or %s placeholders", + }, + "eval_usage": { + "pattern": re.compile(r'\beval\s*\(', re.IGNORECASE), + "severity": SuggestionSeverity.HIGH, + "message": "Use of eval() is dangerous", + "suggested_fix": "Replace with safer alternatives like ast.literal_eval", + }, + "exec_usage": { + "pattern": re.compile(r'\bexec\s*\(', re.IGNORECASE), + "severity": SuggestionSeverity.HIGH, + "message": "Use of exec() is dangerous", + "suggested_fix": "Remove exec() or use safer alternatives", + }, + "shell_injection": { + "pattern": re.compile( + r'(os\.system|subprocess\.(call|run|Popen))\s*\(\s*[f"\'].*\{.*\}.*[f"\']', + re.IGNORECASE, + ), + "severity": SuggestionSeverity.HIGH, + "message": "Shell injection risk", + "suggested_fix": "Use subprocess with shell=False and parameterized arguments", + }, + "hardcoded_secrets": { + "pattern": re.compile( + r'(password|secret|api_key|token)\s*=\s*["\'][^"\']{8,}["\']', + re.IGNORECASE, + ), + "severity": SuggestionSeverity.HIGH, + "message": "Potentially hardcoded secret detected", + "suggested_fix": "Use environment variables or secret management", + }, + } + + def _init_performance_patterns(self) -> Dict[str, Dict[str, Any]]: + """Initialize performance issue patterns.""" + return { + "string_concatenation": { + "pattern": re.compile(r'\+\s*=\s*["\']', re.IGNORECASE), + "severity": SuggestionSeverity.MEDIUM, + "message": "Inefficient string concatenation in loop", + "suggested_fix": "Use list comprehension and join() for better performance", + }, + "global_import": { + "pattern": re.compile(r'^import\s+.*\s*$', re.MULTILINE), + "severity": SuggestionSeverity.LOW, + "message": "Consider moving imports to module level", + "suggested_fix": "Move imports to top of file for better performance", + }, + } + + def analyze_diff( + self, diff_content: str, file_path: str + ) -> List[Suggestion]: + """ + Analyze a git diff for Python code issues. + + Args: + diff_content: The git diff content + file_path: Path to the file being analyzed + + Returns: + List of suggestions found in the diff + """ + suggestions = [] + + # Extract added lines from diff + added_lines = self._extract_added_lines(diff_content) + + for line_num, line_content in added_lines: + suggestions.extend(self._analyze_line(line_content, file_path, line_num)) + + return suggestions + + def analyze_code( + self, content: str, file_path: str + ) -> List[Suggestion]: + """ + Analyze Python code content for issues. + + Args: + content: The Python code content + file_path: Path to the file being analyzed + + Returns: + List of suggestions found in the code + """ + suggestions = [] + + # Pattern-based analysis + lines = content.split("\n") + for line_num, line_content in enumerate(lines, start=1): + suggestions.extend(self._analyze_line(line_content, file_path, line_num)) + + # AST-based analysis + try: + ast_suggestions = self._analyze_ast(content, file_path) + suggestions.extend(ast_suggestions) + except SyntaxError: + # Skip AST analysis if syntax is invalid + pass + + return suggestions + + def _extract_added_lines( + self, diff_content: str + ) -> List[tuple]: + """ + Extract added lines from a git diff. + + Args: + diff_content: The git diff content + + Returns: + List of (line_number, line_content) tuples for added lines + """ + added_lines = [] + current_line_num = 0 + + for line in diff_content.split("\n"): + if line.startswith("@@"): + # Extract line number from hunk header + match = re.search(r'\+(\d+)', line) + if match: + current_line_num = int(match.group(1)) + elif line.startswith("+") and not line.startswith("+++"): + added_lines.append((current_line_num, line[1:])) + current_line_num += 1 + elif not line.startswith("-") and not line.startswith("\\"): + current_line_num += 1 + + return added_lines + + def _analyze_line( + self, line_content: str, file_path: str, line_number: int + ) -> List[Suggestion]: + """ + Analyze a single line of code. + + Args: + line_content: The line content to analyze + file_path: Path to the file + line_number: Line number + + Returns: + List of suggestions found in this line + """ + suggestions = [] + + # Check security patterns + for rule_id, pattern_info in self.security_patterns.items(): + if pattern_info["pattern"].search(line_content): + suggestions.append( + Suggestion( + category=SuggestionCategory.SECURITY, + severity=pattern_info["severity"], + message=pattern_info["message"], + file_path=file_path, + line_number=line_number, + suggested_fix=pattern_info["suggested_fix"], + rule_id=rule_id, + ) + ) + + # Check performance patterns + for rule_id, pattern_info in self.performance_patterns.items(): + if pattern_info["pattern"].search(line_content): + suggestions.append( + Suggestion( + category=SuggestionCategory.PERFORMANCE, + severity=pattern_info["severity"], + message=pattern_info["message"], + file_path=file_path, + line_number=line_number, + suggested_fix=pattern_info["suggested_fix"], + rule_id=rule_id, + ) + ) + + return suggestions + + def _analyze_ast(self, content: str, file_path: str) -> List[Suggestion]: + """ + Analyze Python code using AST. + + Args: + content: The Python code content + file_path: Path to the file + + Returns: + List of suggestions found via AST analysis + """ + suggestions = [] + tree = ast.parse(content) + + class ComplexityVisitor(ast.NodeVisitor): + """AST visitor to check complexity issues.""" + + def __init__(self, suggestions: List[Suggestion], file_path: str): + self.suggestions = suggestions + self.file_path = file_path + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + """Check function complexity.""" + # Count cyclomatic complexity + complexity = 1 # Base complexity + for child in ast.walk(node): + if isinstance(child, (ast.If, ast.While, ast.For, ast.ExceptHandler)): + complexity += 1 + + if complexity > 10: + self.suggestions.append( + Suggestion( + category=SuggestionCategory.COMPLEXITY, + severity=SuggestionSeverity.MEDIUM, + message=f"Function '{node.name}' has high complexity ({complexity})", + file_path=self.file_path, + line_number=node.lineno, + suggested_fix="Consider breaking this function into smaller functions", + rule_id="high_complexity", + ) + ) + + # Check for missing docstring + docstring = ast.get_docstring(node) + if not docstring: + self.suggestions.append( + Suggestion( + category=SuggestionCategory.DOCUMENTATION, + severity=SuggestionSeverity.LOW, + message=f"Function '{node.name}' is missing docstring", + file_path=self.file_path, + line_number=node.lineno, + suggested_fix="Add a docstring to document the function", + rule_id="missing_docstring", + ) + ) + + self.generic_visit(node) + + visitor = ComplexityVisitor(suggestions, file_path) + visitor.visit(tree) + + return suggestions diff --git a/astroml/llm/code_review/analyzers/sql_analyzer.py b/astroml/llm/code_review/analyzers/sql_analyzer.py new file mode 100644 index 0000000..e3ffece --- /dev/null +++ b/astroml/llm/code_review/analyzers/sql_analyzer.py @@ -0,0 +1,173 @@ +""" +SQL-specific code analyzer. + +This module provides analysis capabilities for SQL code, +including pattern matching for common SQL issues. +""" + +import re +from typing import List, Dict, Any + +from astroml.llm.code_review.suggestions import ( + Suggestion, + SuggestionCategory, + SuggestionSeverity, +) + + +class SQLAnalyzer: + """ + Analyzer for SQL code. + + Performs static analysis on SQL code to identify potential issues + related to security, performance, and correctness. + """ + + def __init__(self): + """Initialize the SQL analyzer.""" + self.patterns = self._init_patterns() + + def _init_patterns(self) -> Dict[str, Dict[str, Any]]: + """Initialize SQL analysis patterns.""" + return { + "select_star": { + "pattern": re.compile(r'SELECT\s+\*\s+FROM', re.IGNORECASE), + "severity": SuggestionSeverity.MEDIUM, + "category": SuggestionCategory.PERFORMANCE, + "message": "SELECT * can impact performance", + "suggested_fix": "Specify only the columns you need", + }, + "missing_where": { + "pattern": re.compile( + r'(DELETE\s+FROM|UPDATE\s+\w+\s+SET)\s+\w+\s*(?!WHERE)', + re.IGNORECASE, + ), + "severity": SuggestionSeverity.HIGH, + "category": SuggestionCategory.SECURITY, + "message": "DELETE or UPDATE without WHERE clause", + "suggested_fix": "Add a WHERE clause to limit the scope", + }, + "n_plus_one": { + "pattern": re.compile(r'IN\s*\(\s*SELECT', re.IGNORECASE), + "severity": SuggestionSeverity.MEDIUM, + "category": SuggestionCategory.PERFORMANCE, + "message": "Potential N+1 query pattern detected", + "suggested_fix": "Consider using JOINs instead of subqueries", + }, + "implicit_join": { + "pattern": re.compile( + r'FROM\s+\w+\s*,\s*\w+', re.IGNORECASE + ), + "severity": SuggestionSeverity.LOW, + "category": SuggestionCategory.STYLE, + "message": "Implicit JOIN syntax used", + "suggested_fix": "Use explicit JOIN syntax for better readability", + }, + "like_leading_wildcard": { + "pattern": re.compile(r'LIKE\s+[\'"]%[^%]+', re.IGNORECASE), + "severity": SuggestionSeverity.MEDIUM, + "category": SuggestionCategory.PERFORMANCE, + "message": "LIKE with leading wildcard prevents index usage", + "suggested_fix": "Consider full-text search or avoid leading wildcards", + }, + } + + def analyze_diff( + self, diff_content: str, file_path: str + ) -> List[Suggestion]: + """ + Analyze a git diff for SQL code issues. + + Args: + diff_content: The git diff content + file_path: Path to the file being analyzed + + Returns: + List of suggestions found in the diff + """ + suggestions = [] + added_lines = self._extract_added_lines(diff_content) + + for line_num, line_content in added_lines: + suggestions.extend(self._analyze_line(line_content, file_path, line_num)) + + return suggestions + + def analyze_code( + self, content: str, file_path: str + ) -> List[Suggestion]: + """ + Analyze SQL code content for issues. + + Args: + content: The SQL code content + file_path: Path to the file being analyzed + + Returns: + List of suggestions found in the code + """ + suggestions = [] + lines = content.split("\n") + + for line_num, line_content in enumerate(lines, start=1): + suggestions.extend(self._analyze_line(line_content, file_path, line_num)) + + return suggestions + + def _extract_added_lines(self, diff_content: str) -> List[tuple]: + """ + Extract added lines from a git diff. + + Args: + diff_content: The git diff content + + Returns: + List of (line_number, line_content) tuples for added lines + """ + added_lines = [] + current_line_num = 0 + + for line in diff_content.split("\n"): + if line.startswith("@@"): + match = re.search(r'\+(\d+)', line) + if match: + current_line_num = int(match.group(1)) + elif line.startswith("+") and not line.startswith("+++"): + added_lines.append((current_line_num, line[1:])) + current_line_num += 1 + elif not line.startswith("-") and not line.startswith("\\"): + current_line_num += 1 + + return added_lines + + def _analyze_line( + self, line_content: str, file_path: str, line_number: int + ) -> List[Suggestion]: + """ + Analyze a single line of SQL code. + + Args: + line_content: The line content to analyze + file_path: Path to the file + line_number: Line number + + Returns: + List of suggestions found in this line + """ + suggestions = [] + + for rule_id, pattern_info in self.patterns.items(): + if pattern_info["pattern"].search(line_content): + suggestions.append( + Suggestion( + category=pattern_info["category"], + severity=pattern_info["severity"], + message=pattern_info["message"], + file_path=file_path, + line_number=line_number, + suggested_fix=pattern_info["suggested_fix"], + rule_id=rule_id, + ) + ) + + return suggestions diff --git a/astroml/llm/code_review/analyzers/yaml_analyzer.py b/astroml/llm/code_review/analyzers/yaml_analyzer.py new file mode 100644 index 0000000..56e05e8 --- /dev/null +++ b/astroml/llm/code_review/analyzers/yaml_analyzer.py @@ -0,0 +1,164 @@ +""" +YAML-specific code analyzer. + +This module provides analysis capabilities for YAML code, +including pattern matching for common YAML issues. +""" + +import re +from typing import List, Dict, Any + +from astroml.llm.code_review.suggestions import ( + Suggestion, + SuggestionCategory, + SuggestionSeverity, +) + + +class YAMLAnalyzer: + """ + Analyzer for YAML code. + + Performs static analysis on YAML code to identify potential issues + related to security, correctness, and best practices. + """ + + def __init__(self): + """Initialize the YAML analyzer.""" + self.patterns = self._init_patterns() + + def _init_patterns(self) -> Dict[str, Dict[str, Any]]: + """Initialize YAML analysis patterns.""" + return { + "hardcoded_secret": { + "pattern": re.compile( + r'(password|secret|api_key|token):\s*["\']?[^\s"\']{8,}["\']?', + re.IGNORECASE, + ), + "severity": SuggestionSeverity.HIGH, + "category": SuggestionCategory.SECURITY, + "message": "Potentially hardcoded secret in YAML", + "suggested_fix": "Use environment variable references or secret management", + }, + "debug_enabled": { + "pattern": re.compile(r'debug:\s*true', re.IGNORECASE), + "severity": SuggestionSeverity.MEDIUM, + "category": SuggestionCategory.SECURITY, + "message": "Debug mode enabled in configuration", + "suggested_fix": "Disable debug mode in production configurations", + }, + "insecure_port": { + "pattern": re.compile(r'port:\s*(80|8080|5000)\s*$', re.MULTILINE), + "severity": SuggestionSeverity.LOW, + "category": SuggestionCategory.SECURITY, + "message": "Using non-HTTPS port", + "suggested_fix": "Use HTTPS ports (443, 8443) in production", + }, + "missing_quotes": { + "pattern": re.compile(r'^\s*\w+:\s*[^"\'].*$', re.MULTILINE), + "severity": SuggestionSeverity.LOW, + "category": SuggestionCategory.STYLE, + "message": "Unquoted value may cause type ambiguity", + "suggested_fix": "Quote string values to avoid type ambiguity", + }, + } + + def analyze_diff( + self, diff_content: str, file_path: str + ) -> List[Suggestion]: + """ + Analyze a git diff for YAML code issues. + + Args: + diff_content: The git diff content + file_path: Path to the file being analyzed + + Returns: + List of suggestions found in the diff + """ + suggestions = [] + added_lines = self._extract_added_lines(diff_content) + + for line_num, line_content in added_lines: + suggestions.extend(self._analyze_line(line_content, file_path, line_num)) + + return suggestions + + def analyze_code( + self, content: str, file_path: str + ) -> List[Suggestion]: + """ + Analyze YAML code content for issues. + + Args: + content: The YAML code content + file_path: Path to the file being analyzed + + Returns: + List of suggestions found in the code + """ + suggestions = [] + lines = content.split("\n") + + for line_num, line_content in enumerate(lines, start=1): + suggestions.extend(self._analyze_line(line_content, file_path, line_num)) + + return suggestions + + def _extract_added_lines(self, diff_content: str) -> List[tuple]: + """ + Extract added lines from a git diff. + + Args: + diff_content: The git diff content + + Returns: + List of (line_number, line_content) tuples for added lines + """ + added_lines = [] + current_line_num = 0 + + for line in diff_content.split("\n"): + if line.startswith("@@"): + match = re.search(r'\+(\d+)', line) + if match: + current_line_num = int(match.group(1)) + elif line.startswith("+") and not line.startswith("+++"): + added_lines.append((current_line_num, line[1:])) + current_line_num += 1 + elif not line.startswith("-") and not line.startswith("\\"): + current_line_num += 1 + + return added_lines + + def _analyze_line( + self, line_content: str, file_path: str, line_number: int + ) -> List[Suggestion]: + """ + Analyze a single line of YAML code. + + Args: + line_content: The line content to analyze + file_path: Path to the file + line_number: Line number + + Returns: + List of suggestions found in this line + """ + suggestions = [] + + for rule_id, pattern_info in self.patterns.items(): + if pattern_info["pattern"].search(line_content): + suggestions.append( + Suggestion( + category=pattern_info["category"], + severity=pattern_info["severity"], + message=pattern_info["message"], + file_path=file_path, + line_number=line_number, + suggested_fix=pattern_info["suggested_fix"], + rule_id=rule_id, + ) + ) + + return suggestions diff --git a/astroml/llm/code_review/checks/__init__.py b/astroml/llm/code_review/checks/__init__.py new file mode 100644 index 0000000..042a011 --- /dev/null +++ b/astroml/llm/code_review/checks/__init__.py @@ -0,0 +1,30 @@ +""" +Review check implementations. + +This package contains various checks for code review: +- Security checks +- Performance checks +- Style checks +- Correctness checks +- Testing checks +- Documentation checks +- Complexity checks +""" + +from astroml.llm.code_review.checks.security import SecurityCheck +from astroml.llm.code_review.checks.performance import PerformanceCheck +from astroml.llm.code_review.checks.style import StyleCheck +from astroml.llm.code_review.checks.correctness import CorrectnessCheck +from astroml.llm.code_review.checks.testing import TestingCheck +from astroml.llm.code_review.checks.documentation import DocumentationCheck +from astroml.llm.code_review.checks.complexity import ComplexityCheck + +__all__ = [ + "SecurityCheck", + "PerformanceCheck", + "StyleCheck", + "CorrectnessCheck", + "TestingCheck", + "DocumentationCheck", + "ComplexityCheck", +] diff --git a/astroml/llm/code_review/checks/complexity.py b/astroml/llm/code_review/checks/complexity.py new file mode 100644 index 0000000..ed69814 --- /dev/null +++ b/astroml/llm/code_review/checks/complexity.py @@ -0,0 +1,215 @@ +""" +Complexity checks for code review. + +This module implements complexity-focused checks for code review, +including cyclomatic complexity, function length, and nesting depth. +""" + +import ast +from typing import List + +from astroml.llm.code_review.checks.security import BaseCheck +from astroml.llm.code_review.suggestions import ( + Suggestion, + SuggestionCategory, + SuggestionSeverity, +) + + +class ComplexityCheck(BaseCheck): + """ + Complexity-focused code review checks. + + Checks for complexity issues including: + - Cyclomatic complexity + - Function length + - Nesting depth + - Parameter count + """ + + def __init__(self): + """Initialize the complexity check.""" + self.max_complexity = 10 + self.max_function_length = 50 + self.max_nesting_depth = 4 + self.max_parameters = 7 + + def check(self, content: str, file_path: str) -> List[Suggestion]: + """ + Perform complexity checks on the given content. + + Args: + content: The code content to check + file_path: Path to the file being checked + + Returns: + List of complexity suggestions found + """ + suggestions = [] + + try: + tree = ast.parse(content) + suggestions.extend(self._check_complexity(tree, file_path)) + suggestions.extend(self._check_function_length(tree, file_path)) + suggestions.extend(self._check_nesting_depth(tree, file_path)) + suggestions.extend(self._check_parameter_count(tree, file_path)) + except SyntaxError: + # Skip AST analysis if syntax is invalid + pass + + return suggestions + + def _check_complexity(self, tree: ast.AST, file_path: str) -> List[Suggestion]: + """Check cyclomatic complexity of functions.""" + suggestions = [] + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + complexity = self._calculate_complexity(node) + if complexity > self.max_complexity: + suggestions.append( + Suggestion( + category=SuggestionCategory.COMPLEXITY, + severity=SuggestionSeverity.MEDIUM, + message=f"Function '{node.name}' has high cyclomatic complexity ({complexity})", + file_path=file_path, + line_number=node.lineno, + suggested_fix="Consider breaking this function into smaller functions", + rule_id="high_complexity", + ) + ) + + return suggestions + + def _calculate_complexity(self, node: ast.FunctionDef) -> int: + """Calculate cyclomatic complexity of a function.""" + complexity = 1 # Base complexity + + for child in ast.walk(node): + if isinstance( + child, + ( + ast.If, + ast.While, + ast.For, + ast.AsyncFor, + ast.ExceptHandler, + ast.With, + ast.AsyncWith, + ), + ): + complexity += 1 + elif isinstance(child, ast.BoolOp): + complexity += len(child.values) - 1 + + return complexity + + def _check_function_length(self, tree: ast.AST, file_path: str) -> List[Suggestion]: + """Check function length in lines.""" + suggestions = [] + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + length = node.end_lineno - node.lineno + 1 if node.end_lineno else 0 + if length > self.max_function_length: + suggestions.append( + Suggestion( + category=SuggestionCategory.COMPLEXITY, + severity=SuggestionSeverity.MEDIUM, + message=f"Function '{node.name}' is too long ({length} lines)", + file_path=file_path, + line_number=node.lineno, + suggested_fix="Consider breaking this function into smaller functions", + rule_id="long_function", + ) + ) + + return suggestions + + def _check_nesting_depth(self, tree: ast.AST, file_path: str) -> List[Suggestion]: + """Check nesting depth of code blocks.""" + suggestions = [] + + class NestingDepthVisitor(ast.NodeVisitor): + def __init__(self, max_depth: int): + self.max_depth = max_depth + self.suggestions = [] + self.current_depth = 0 + + def visit_If(self, node: ast.If) -> None: + self.current_depth += 1 + if self.current_depth > self.max_depth: + self.suggestions.append( + Suggestion( + category=SuggestionCategory.COMPLEXITY, + severity=SuggestionSeverity.MEDIUM, + message=f"Nesting depth {self.current_depth} exceeds maximum", + file_path=file_path, + line_number=node.lineno, + suggested_fix="Consider extracting nested logic into separate functions", + rule_id="deep_nesting", + ) + ) + self.generic_visit(node) + self.current_depth -= 1 + + def visit_For(self, node: ast.For) -> None: + self.current_depth += 1 + if self.current_depth > self.max_depth: + self.suggestions.append( + Suggestion( + category=SuggestionCategory.COMPLEXITY, + severity=SuggestionSeverity.MEDIUM, + message=f"Nesting depth {self.current_depth} exceeds maximum", + file_path=file_path, + line_number=node.lineno, + suggested_fix="Consider extracting nested logic into separate functions", + rule_id="deep_nesting", + ) + ) + self.generic_visit(node) + self.current_depth -= 1 + + def visit_While(self, node: ast.While) -> None: + self.current_depth += 1 + if self.current_depth > self.max_depth: + self.suggestions.append( + Suggestion( + category=SuggestionCategory.COMPLEXITY, + severity=SuggestionSeverity.MEDIUM, + message=f"Nesting depth {self.current_depth} exceeds maximum", + file_path=file_path, + line_number=node.lineno, + suggested_fix="Consider extracting nested logic into separate functions", + rule_id="deep_nesting", + ) + ) + self.generic_visit(node) + self.current_depth -= 1 + + visitor = NestingDepthVisitor(self.max_nesting_depth) + visitor.visit(tree) + + return visitor.suggestions + + def _check_parameter_count(self, tree: ast.AST, file_path: str) -> List[Suggestion]: + """Check parameter count of functions.""" + suggestions = [] + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + param_count = len(node.args.args) + if param_count > self.max_parameters: + suggestions.append( + Suggestion( + category=SuggestionCategory.COMPLEXITY, + severity=SuggestionSeverity.LOW, + message=f"Function '{node.name}' has many parameters ({param_count})", + file_path=file_path, + line_number=node.lineno, + suggested_fix="Consider using a dataclass or configuration object", + rule_id="many_parameters", + ) + ) + + return suggestions diff --git a/astroml/llm/code_review/checks/correctness.py b/astroml/llm/code_review/checks/correctness.py new file mode 100644 index 0000000..9b8069c --- /dev/null +++ b/astroml/llm/code_review/checks/correctness.py @@ -0,0 +1,105 @@ +""" +Correctness checks for code review. + +This module implements correctness-focused checks for code review, +including logic errors, edge cases, and potential bugs. +""" + +import re +from typing import List + +from astroml.llm.code_review.checks.security import BaseCheck +from astroml.llm.code_review.suggestions import ( + Suggestion, + SuggestionCategory, + SuggestionSeverity, +) + + +class CorrectnessCheck(BaseCheck): + """ + Correctness-focused code review checks. + + Checks for correctness issues including: + - Logic errors + - Edge cases + - Type errors + - Null pointer risks + - Off-by-one errors + """ + + def __init__(self): + """Initialize the correctness check.""" + self.patterns = self._init_patterns() + + def _init_patterns(self) -> dict: + """Initialize correctness issue patterns.""" + return { + "none_comparison": { + "pattern": r'==\s*None|!=\s*None', + "severity": SuggestionSeverity.MEDIUM, + "message": "Use 'is None' or 'is not None' instead of == or !=", + "suggested_fix": "Use 'is None' or 'is not None' for None comparisons", + }, + "mutable_default_arg": { + "pattern": r'def\s+\w+\([^)]*=\s*\[|\{', + "severity": SuggestionSeverity.HIGH, + "message": "Mutable default argument detected", + "suggested_fix": "Use None as default and initialize inside function", + }, + "except_bare": { + "pattern": r'except\s*:', + "severity": SuggestionSeverity.HIGH, + "message": "Bare except clause catches all exceptions", + "suggested_fix": "Specify the exception type to catch", + }, + "return_in_finally": { + "pattern": r'finally:\s*return', + "severity": SuggestionSeverity.HIGH, + "message": "Return in finally block suppresses exceptions", + "suggested_fix": "Move return outside finally block", + }, + "unused_variable": { + "pattern": r'_\s*=', + "severity": SuggestionSeverity.LOW, + "message": "Variable assigned but not used", + "suggested_fix": "Remove unused variable or use proper naming", + }, + "comparison_literal": { + "pattern": r'(True|False)\s*==\s*\w+|\w+\s*==\s*(True|False)', + "severity": SuggestionSeverity.LOW, + "message": "Comparison with boolean literal is redundant", + "suggested_fix": "Use the boolean directly or 'if not x'", + }, + } + + def check(self, content: str, file_path: str) -> List[Suggestion]: + """ + Perform correctness checks on the given content. + + Args: + content: The code content to check + file_path: Path to the file being checked + + Returns: + List of correctness suggestions found + """ + suggestions = [] + + lines = content.split("\n") + for line_num, line_content in enumerate(lines, start=1): + for rule_id, pattern_info in self.patterns.items(): + if re.search(pattern_info["pattern"], line_content): + suggestions.append( + Suggestion( + category=SuggestionCategory.CORRECTNESS, + severity=pattern_info["severity"], + message=pattern_info["message"], + file_path=file_path, + line_number=line_num, + suggested_fix=pattern_info["suggested_fix"], + rule_id=f"correctness_{rule_id}", + ) + ) + + return suggestions diff --git a/astroml/llm/code_review/checks/documentation.py b/astroml/llm/code_review/checks/documentation.py new file mode 100644 index 0000000..7567e86 --- /dev/null +++ b/astroml/llm/code_review/checks/documentation.py @@ -0,0 +1,122 @@ +""" +Documentation checks for code review. + +This module implements documentation-focused checks for code review, +including missing docstrings, incomplete documentation, and documentation quality. +""" + +import re +import ast +from typing import List + +from astroml.llm.code_review.checks.security import BaseCheck +from astroml.llm.code_review.suggestions import ( + Suggestion, + SuggestionCategory, + SuggestionSeverity, +) + + +class DocumentationCheck(BaseCheck): + """ + Documentation-focused code review checks. + + Checks for documentation issues including: + - Missing docstrings + - Incomplete documentation + - Documentation quality + - Type hints + """ + + def __init__(self): + """Initialize the documentation check.""" + self.patterns = self._init_patterns() + + def _init_patterns(self) -> dict: + """Initialize documentation issue patterns.""" + return { + "todo_without_issue": { + "pattern": r'#\s*TODO(?!\s*#\s*\d+)', + "severity": SuggestionSeverity.LOW, + "message": "TODO comment without issue reference", + "suggested_fix": "Add issue reference to TODO comment", + }, + "fixme_without_issue": { + "pattern": r'#\s*FIXME(?!\s*#\s*\d+)', + "severity": SuggestionSeverity.LOW, + "message": "FIXME comment without issue reference", + "suggested_fix": "Add issue reference to FIXME comment", + }, + } + + def check(self, content: str, file_path: str) -> List[Suggestion]: + """ + Perform documentation checks on the given content. + + Args: + content: The code content to check + file_path: Path to the file being checked + + Returns: + List of documentation suggestions found + """ + suggestions = [] + + # Pattern-based checks + lines = content.split("\n") + for line_num, line_content in enumerate(lines, start=1): + for rule_id, pattern_info in self.patterns.items(): + if re.search(pattern_info["pattern"], line_content): + suggestions.append( + Suggestion( + category=SuggestionCategory.DOCUMENTATION, + severity=pattern_info["severity"], + message=pattern_info["message"], + file_path=file_path, + line_number=line_num, + suggested_fix=pattern_info["suggested_fix"], + rule_id=f"documentation_{rule_id}", + ) + ) + + # AST-based checks for docstrings + try: + ast_suggestions = self._check_docstrings(content, file_path) + suggestions.extend(ast_suggestions) + except SyntaxError: + # Skip AST analysis if syntax is invalid + pass + + return suggestions + + def _check_docstrings(self, content: str, file_path: str) -> List[Suggestion]: + """ + Check for missing docstrings using AST. + + Args: + content: The code content + file_path: Path to the file + + Returns: + List of docstring-related suggestions + """ + suggestions = [] + tree = ast.parse(content) + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.AsyncFunctionDef)): + docstring = ast.get_docstring(node) + if not docstring: + suggestions.append( + Suggestion( + category=SuggestionCategory.DOCUMENTATION, + severity=SuggestionSeverity.LOW, + message=f"{node.__class__.__name__} '{node.name}' is missing docstring", + file_path=file_path, + line_number=node.lineno, + suggested_fix="Add a docstring to document this function/class", + rule_id="missing_docstring", + ) + ) + + return suggestions diff --git a/astroml/llm/code_review/checks/performance.py b/astroml/llm/code_review/checks/performance.py new file mode 100644 index 0000000..2f180f1 --- /dev/null +++ b/astroml/llm/code_review/checks/performance.py @@ -0,0 +1,105 @@ +""" +Performance checks for code review. + +This module implements performance-focused checks for code review, +including inefficient algorithms, memory leaks, and N+1 queries. +""" + +import re +from typing import List + +from astroml.llm.code_review.checks.security import BaseCheck +from astroml.llm.code_review.suggestions import ( + Suggestion, + SuggestionCategory, + SuggestionSeverity, +) + + +class PerformanceCheck(BaseCheck): + """ + Performance-focused code review checks. + + Checks for common performance issues including: + - Inefficient algorithms + - Memory leaks + - N+1 queries + - Unnecessary computations + - Poor database usage + """ + + def __init__(self): + """Initialize the performance check.""" + self.patterns = self._init_patterns() + + def _init_patterns(self) -> dict: + """Initialize performance issue patterns.""" + return { + "nested_loop_o_n2": { + "pattern": r'for\s+\w+\s+in\s+.*:\s*for\s+\w+\s+in\s+.*:', + "severity": SuggestionSeverity.MEDIUM, + "message": "Nested loops may indicate O(n²) complexity", + "suggested_fix": "Consider using sets, dictionaries, or more efficient algorithms", + }, + "list_append_in_loop": { + "pattern": r'for\s+\w+\s+in\s+.*:\s*.*\.append\(', + "severity": SuggestionSeverity.LOW, + "message": "List append in loop - consider list comprehension", + "suggested_fix": "Use list comprehension for better performance", + }, + "string_concat_loop": { + "pattern": r'\+=\s*["\']', + "severity": SuggestionSeverity.MEDIUM, + "message": "String concatenation in loop is inefficient", + "suggested_fix": "Use list and join() for better performance", + }, + "global_variable_mutation": { + "pattern": r'global\s+\w+', + "severity": SuggestionSeverity.LOW, + "message": "Global variable mutation can impact performance", + "suggested_fix": "Consider using function arguments or class attributes", + }, + "database_query_in_loop": { + "pattern": r'(execute|query|select)\s*\(', + "severity": SuggestionSeverity.HIGH, + "message": "Database query inside loop - potential N+1 issue", + "suggested_fix": "Use batch operations or eager loading", + }, + "synchronous_io_in_async": { + "pattern": r'(time\.sleep|requests\.get|urllib\.request)', + "severity": SuggestionSeverity.HIGH, + "message": "Synchronous I/O in async context", + "suggested_fix": "Use async alternatives (aiohttp, asyncio.sleep)", + }, + } + + def check(self, content: str, file_path: str) -> List[Suggestion]: + """ + Perform performance checks on the given content. + + Args: + content: The code content to check + file_path: Path to the file being checked + + Returns: + List of performance suggestions found + """ + suggestions = [] + + lines = content.split("\n") + for line_num, line_content in enumerate(lines, start=1): + for rule_id, pattern_info in self.patterns.items(): + if re.search(pattern_info["pattern"], line_content, re.IGNORECASE): + suggestions.append( + Suggestion( + category=SuggestionCategory.PERFORMANCE, + severity=pattern_info["severity"], + message=pattern_info["message"], + file_path=file_path, + line_number=line_num, + suggested_fix=pattern_info["suggested_fix"], + rule_id=f"performance_{rule_id}", + ) + ) + + return suggestions diff --git a/astroml/llm/code_review/checks/security.py b/astroml/llm/code_review/checks/security.py new file mode 100644 index 0000000..d2ca87e --- /dev/null +++ b/astroml/llm/code_review/checks/security.py @@ -0,0 +1,123 @@ +""" +Security checks for code review. + +This module implements security-focused checks for code review, +including SQL injection, XSS, authentication issues, and more. +""" + +from abc import ABC, abstractmethod +from typing import List + +from astroml.llm.code_review.suggestions import ( + Suggestion, + SuggestionCategory, + SuggestionSeverity, +) + + +class BaseCheck(ABC): + """Base class for code review checks.""" + + @abstractmethod + def check(self, content: str, file_path: str) -> List[Suggestion]: + """ + Perform the check on the given content. + + Args: + content: The code content to check + file_path: Path to the file being checked + + Returns: + List of suggestions found + """ + pass + + +class SecurityCheck(BaseCheck): + """ + Security-focused code review checks. + + Checks for common security vulnerabilities including: + - SQL injection + - XSS vulnerabilities + - Authentication issues + - Hardcoded secrets + - Insecure dependencies + """ + + def __init__(self): + """Initialize the security check.""" + self.vulnerability_patterns = self._init_patterns() + + def _init_patterns(self) -> dict: + """Initialize security vulnerability patterns.""" + return { + "sql_injection_fstring": { + "pattern": r'execute\s*\(\s*f["\'].*\{.*\}.*["\']', + "severity": SuggestionSeverity.HIGH, + "message": "SQL injection risk via f-string", + "suggested_fix": "Use parameterized queries with ? or %s placeholders", + }, + "xss_render": { + "pattern": r'render\s*\(\s*.*\|\s*safe', + "severity": SuggestionSeverity.HIGH, + "message": "XSS risk: using | safe filter on user input", + "suggested_fix": "Avoid using | safe on untrusted user input", + }, + "hardcoded_password": { + "pattern": r'(password|passwd|secret)\s*=\s*["\'][^"\']{8,}["\']', + "severity": SuggestionSeverity.HIGH, + "message": "Hardcoded password detected", + "suggested_fix": "Use environment variables or secret management", + }, + "weak_hash": { + "pattern": r'(md5|sha1)\s*\(', + "severity": SuggestionSeverity.MEDIUM, + "message": "Weak cryptographic hash algorithm", + "suggested_fix": "Use stronger algorithms like SHA-256 or SHA-512", + }, + "random_not_crypto": { + "pattern": r'import\s+random\s*$', + "severity": SuggestionSeverity.MEDIUM, + "message": "Using random module for security-sensitive operations", + "suggested_fix": "Use secrets module for cryptographic operations", + }, + "verify_disabled_ssl": { + "pattern": r'verify\s*=\s*False', + "severity": SuggestionSeverity.HIGH, + "message": "SSL verification disabled", + "suggested_fix": "Enable SSL verification for secure connections", + }, + } + + def check(self, content: str, file_path: str) -> List[Suggestion]: + """ + Perform security checks on the given content. + + Args: + content: The code content to check + file_path: Path to the file being checked + + Returns: + List of security suggestions found + """ + suggestions = [] + import re + + lines = content.split("\n") + for line_num, line_content in enumerate(lines, start=1): + for rule_id, pattern_info in self.vulnerability_patterns.items(): + if re.search(pattern_info["pattern"], line_content, re.IGNORECASE): + suggestions.append( + Suggestion( + category=SuggestionCategory.SECURITY, + severity=pattern_info["severity"], + message=pattern_info["message"], + file_path=file_path, + line_number=line_num, + suggested_fix=pattern_info["suggested_fix"], + rule_id=f"security_{rule_id}", + ) + ) + + return suggestions diff --git a/astroml/llm/code_review/checks/style.py b/astroml/llm/code_review/checks/style.py new file mode 100644 index 0000000..d1f4d2b --- /dev/null +++ b/astroml/llm/code_review/checks/style.py @@ -0,0 +1,104 @@ +""" +Style checks for code review. + +This module implements style-focused checks for code review, +including PEP8 compliance, naming conventions, and best practices. +""" + +import re +from typing import List + +from astroml.llm.code_review.checks.security import BaseCheck +from astroml.llm.code_review.suggestions import ( + Suggestion, + SuggestionCategory, + SuggestionSeverity, +) + + +class StyleCheck(BaseCheck): + """ + Style-focused code review checks. + + Checks for style issues including: + - PEP8 violations + - Naming conventions + - Code formatting + - Best practices + """ + + def __init__(self): + """Initialize the style check.""" + self.patterns = self._init_patterns() + + def _init_patterns(self) -> dict: + """Initialize style issue patterns.""" + return { + "line_too_long": { + "pattern": r'.{120,}', + "severity": SuggestionSeverity.LOW, + "message": "Line exceeds 120 characters", + "suggested_fix": "Break the line into multiple lines", + }, + "trailing_whitespace": { + "pattern": r'\s+$', + "severity": SuggestionSeverity.LOW, + "message": "Trailing whitespace", + "suggested_fix": "Remove trailing whitespace", + }, + "magic_number": { + "pattern": r'\b\d{2,}\b', + "severity": SuggestionSeverity.LOW, + "message": "Magic number detected", + "suggested_fix": "Extract to a named constant", + }, + "camel_case_variable": { + "pattern": r'[a-z][A-Z]', + "severity": SuggestionSeverity.LOW, + "message": "Variable name uses camelCase instead of snake_case", + "suggested_fix": "Use snake_case for variable names", + }, + "unused_import": { + "pattern": r'^import\s+\w+.*$', + "severity": SuggestionSeverity.LOW, + "message": "Import may be unused (heuristic)", + "suggested_fix": "Remove unused imports", + }, + "commented_code": { + "pattern": r'^\s*#.*[=;(){}\[\]]', + "severity": SuggestionSeverity.LOW, + "message": "Commented-out code detected", + "suggested_fix": "Remove commented code or add TODO comment", + }, + } + + def check(self, content: str, file_path: str) -> List[Suggestion]: + """ + Perform style checks on the given content. + + Args: + content: The code content to check + file_path: Path to the file being checked + + Returns: + List of style suggestions found + """ + suggestions = [] + + lines = content.split("\n") + for line_num, line_content in enumerate(lines, start=1): + for rule_id, pattern_info in self.patterns.items(): + if re.search(pattern_info["pattern"], line_content): + suggestions.append( + Suggestion( + category=SuggestionCategory.STYLE, + severity=pattern_info["severity"], + message=pattern_info["message"], + file_path=file_path, + line_number=line_num, + suggested_fix=pattern_info["suggested_fix"], + rule_id=f"style_{rule_id}", + ) + ) + + return suggestions diff --git a/astroml/llm/code_review/checks/testing.py b/astroml/llm/code_review/checks/testing.py new file mode 100644 index 0000000..cb9fc7f --- /dev/null +++ b/astroml/llm/code_review/checks/testing.py @@ -0,0 +1,98 @@ +""" +Testing checks for code review. + +This module implements testing-focused checks for code review, +including missing tests, weak assertions, and test coverage. +""" + +import re +from typing import List + +from astroml.llm.code_review.checks.security import BaseCheck +from astroml.llm.code_review.suggestions import ( + Suggestion, + SuggestionCategory, + SuggestionSeverity, +) + + +class TestingCheck(BaseCheck): + """ + Testing-focused code review checks. + + Checks for testing issues including: + - Missing tests + - Weak assertions + - Test coverage gaps + - Test quality issues + """ + + def __init__(self): + """Initialize the testing check.""" + self.patterns = self._init_patterns() + + def _init_patterns(self) -> dict: + """Initialize testing issue patterns.""" + return { + "assert_without_message": { + "pattern": r'assert\s+\w+', + "severity": SuggestionSeverity.LOW, + "message": "Assert without message makes debugging harder", + "suggested_fix": "Add a message to assert for better debugging", + }, + "pass_in_test": { + "pattern": r'def\s+test_\w+.*:\s*pass', + "severity": SuggestionSeverity.MEDIUM, + "message": "Test function with only 'pass' statement", + "suggested_fix": "Implement the test or remove the function", + }, + "print_in_test": { + "pattern": r'print\s*\(', + "severity": SuggestionSeverity.LOW, + "message": "Print statement in test function", + "suggested_fix": "Use assertions instead of print statements", + }, + "no_assertions": { + "pattern": r'def\s+test_\w+.*:(?!.*assert)', + "severity": SuggestionSeverity.MEDIUM, + "message": "Test function may lack assertions", + "suggested_fix": "Add assertions to verify expected behavior", + }, + "mock_unused": { + "pattern": r'@patch.*\ndef\s+test_\w+', + "severity": SuggestionSeverity.LOW, + "message": "Mock decorator used but may not be utilized", + "suggested_fix": "Ensure mock is properly used in the test", + }, + } + + def check(self, content: str, file_path: str) -> List[Suggestion]: + """ + Perform testing checks on the given content. + + Args: + content: The code content to check + file_path: Path to the file being checked + + Returns: + List of testing suggestions found + """ + suggestions = [] + + lines = content.split("\n") + for line_num, line_content in enumerate(lines, start=1): + for rule_id, pattern_info in self.patterns.items(): + if re.search(pattern_info["pattern"], line_content): + suggestions.append( + Suggestion( + category=SuggestionCategory.TESTING, + severity=pattern_info["severity"], + message=pattern_info["message"], + file_path=file_path, + line_number=line_num, + suggested_fix=pattern_info["suggested_fix"], + rule_id=f"testing_{rule_id}", + ) + ) + + return suggestions diff --git a/astroml/llm/code_review/reviewer.py b/astroml/llm/code_review/reviewer.py new file mode 100644 index 0000000..94bfb27 --- /dev/null +++ b/astroml/llm/code_review/reviewer.py @@ -0,0 +1,445 @@ +""" +Main code review logic. + +This module implements the core code review functionality, +coordinating analyzers and checks to provide comprehensive code review. +""" + +import os +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Dict, Optional, Set +from enum import Enum + +from astroml.llm.code_review.suggestions import ( + Suggestion, + SuggestionCategory, + SuggestionGroup, + SuggestionSeverity, +) +from astroml.llm.code_review.analyzers.python_analyzer import PythonAnalyzer +from astroml.llm.code_review.analyzers.sql_analyzer import SQLAnalyzer +from astroml.llm.code_review.analyzers.yaml_analyzer import YAMLAnalyzer +from astroml.llm.code_review.checks.security import SecurityCheck +from astroml.llm.code_review.checks.performance import PerformanceCheck +from astroml.llm.code_review.checks.style import StyleCheck +from astroml.llm.code_review.checks.correctness import CorrectnessCheck +from astroml.llm.code_review.checks.testing import TestingCheck +from astroml.llm.code_review.checks.documentation import DocumentationCheck +from astroml.llm.code_review.checks.complexity import ComplexityCheck + + +class ReviewStatus(Enum): + """Status of a code review.""" + + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + FAILED = "failed" + + +@dataclass +class ReviewResult: + """ + Result of a code review. + + Attributes: + status: The review status + suggestions: List of all suggestions found + suggestion_groups: Suggestions grouped by category + files_reviewed: Number of files reviewed + duration_seconds: Time taken for the review + error: Optional error message if review failed + """ + + status: ReviewStatus + suggestions: List[Suggestion] = field(default_factory=list) + suggestion_groups: List[SuggestionGroup] = field(default_factory=list) + files_reviewed: int = 0 + duration_seconds: float = 0.0 + error: Optional[str] = None + + def format_markdown(self) -> str: + """ + Format the review result as markdown. + + Returns: + Formatted markdown string suitable for PR comments + """ + if self.status == ReviewStatus.FAILED: + return f"## Code Review Failed\n\nError: {self.error}" + + if not self.suggestions: + return "## Code Review\n\nNo issues found! šŸŽ‰" + + lines = ["## Code Review Results"] + lines.append(f"\nReviewed {self.files_reviewed} file(s) in {self.duration_seconds:.2f}s") + lines.append(f"\nFound {len(self.suggestions)} issue(s):\n") + + for group in self.suggestion_groups: + lines.append(group.format_markdown()) + lines.append("") + + return "\n".join(lines) + + def get_summary(self) -> Dict[str, int]: + """ + Get a summary of suggestions by category and severity. + + Returns: + Dictionary with counts by category and severity + """ + summary = {} + + for category in SuggestionCategory: + category_key = category.value.lower() + summary[category_key] = 0 + + for severity in SuggestionSeverity: + severity_key = f"{severity.value.lower()}_count" + summary[severity_key] = 0 + + for suggestion in self.suggestions: + category_key = suggestion.category.value.lower() + summary[category_key] = summary.get(category_key, 0) + 1 + + severity_key = f"{suggestion.severity.value.lower()}_count" + summary[severity_key] = summary.get(severity_key, 0) + 1 + + return summary + + +class CodeReviewer: + """ + Main code review engine. + + Coordinates language-specific analyzers and category-specific checks + to provide comprehensive code review capabilities. + """ + + def __init__( + self, + enable_llm: bool = False, + max_review_time: int = 120, + ignored_rules: Optional[Set[str]] = None, + ): + """ + Initialize the code reviewer. + + Args: + enable_llm: Whether to enable LLM-powered analysis + max_review_time: Maximum time in seconds for review + ignored_rules: Set of rule IDs to ignore + """ + self.enable_llm = enable_llm + self.max_review_time = max_review_time + self.ignored_rules = ignored_rules or set() + + # Initialize analyzers + self.analyzers = { + ".py": PythonAnalyzer(), + ".sql": SQLAnalyzer(), + ".yaml": YAMLAnalyzer(), + ".yml": YAMLAnalyzer(), + } + + # Initialize checks + self.checks = [ + SecurityCheck(), + PerformanceCheck(), + StyleCheck(), + CorrectnessCheck(), + TestingCheck(), + DocumentationCheck(), + ComplexityCheck(), + ] + + # Learning from human decisions (simple storage) + self.accepted_suggestions: Dict[str, int] = {} + self.rejected_suggestions: Dict[str, int] = {} + + def review_diff( + self, diff_content: str, file_path: str + ) -> ReviewResult: + """ + Review a git diff for code issues. + + Args: + diff_content: The git diff content + file_path: Path to the file being reviewed + + Returns: + ReviewResult with suggestions found + """ + start_time = time.time() + suggestions = [] + + try: + # Get file extension + file_ext = Path(file_path).suffix + + # Use language-specific analyzer if available + if file_ext in self.analyzers: + analyzer = self.analyzers[file_ext] + analyzer_suggestions = analyzer.analyze_diff(diff_content, file_path) + suggestions.extend(analyzer_suggestions) + + # Apply all checks to the diff content + for check in self.checks: + check_suggestions = check.check(diff_content, file_path) + suggestions.extend(check_suggestions) + + # Filter out ignored rules + suggestions = [ + s for s in suggestions if s.rule_id not in self.ignored_rules + ] + + # Group suggestions by category + suggestion_groups = self._group_suggestions(suggestions) + + duration = time.time() - start_time + + return ReviewResult( + status=ReviewStatus.COMPLETED, + suggestions=suggestions, + suggestion_groups=suggestion_groups, + files_reviewed=1, + duration_seconds=duration, + ) + + except Exception as e: + duration = time.time() - start_time + return ReviewResult( + status=ReviewStatus.FAILED, + error=str(e), + duration_seconds=duration, + ) + + def review_file(self, file_path: str) -> ReviewResult: + """ + Review a single file for code issues. + + Args: + file_path: Path to the file to review + + Returns: + ReviewResult with suggestions found + """ + start_time = time.time() + suggestions = [] + + try: + # Read file content + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # Get file extension + file_ext = Path(file_path).suffix + + # Use language-specific analyzer if available + if file_ext in self.analyzers: + analyzer = self.analyzers[file_ext] + analyzer_suggestions = analyzer.analyze_code(content, file_path) + suggestions.extend(analyzer_suggestions) + + # Apply all checks to the content + for check in self.checks: + check_suggestions = check.check(content, file_path) + suggestions.extend(check_suggestions) + + # Filter out ignored rules + suggestions = [ + s for s in suggestions if s.rule_id not in self.ignored_rules + ] + + # Group suggestions by category + suggestion_groups = self._group_suggestions(suggestions) + + duration = time.time() - start_time + + return ReviewResult( + status=ReviewStatus.COMPLETED, + suggestions=suggestions, + suggestion_groups=suggestion_groups, + files_reviewed=1, + duration_seconds=duration, + ) + + except Exception as e: + duration = time.time() - start_time + return ReviewResult( + status=ReviewStatus.FAILED, + error=str(e), + duration_seconds=duration, + ) + + def review_directory( + self, directory_path: str, file_patterns: Optional[List[str]] = None + ) -> ReviewResult: + """ + Review all files in a directory. + + Args: + directory_path: Path to the directory to review + file_patterns: Optional list of file patterns to include + + Returns: + ReviewResult with aggregated suggestions + """ + start_time = time.time() + all_suggestions = [] + files_reviewed = 0 + + try: + directory = Path(directory_path) + + # Default file patterns if none provided + if file_patterns is None: + file_patterns = ["*.py", "*.sql", "*.yaml", "*.yml"] + + # Find matching files + for pattern in file_patterns: + for file_path in directory.rglob(pattern): + if file_path.is_file(): + result = self.review_file(str(file_path)) + if result.status == ReviewStatus.COMPLETED: + all_suggestions.extend(result.suggestions) + files_reviewed += 1 + + # Group suggestions by category + suggestion_groups = self._group_suggestions(all_suggestions) + + duration = time.time() - start_time + + return ReviewResult( + status=ReviewStatus.COMPLETED, + suggestions=all_suggestions, + suggestion_groups=suggestion_groups, + files_reviewed=files_reviewed, + duration_seconds=duration, + ) + + except Exception as e: + duration = time.time() - start_time + return ReviewResult( + status=ReviewStatus.FAILED, + error=str(e), + duration_seconds=duration, + ) + + def review_pr( + self, pr_diff: Dict[str, str] + ) -> ReviewResult: + """ + Review a pull request by analyzing its diff. + + Args: + pr_diff: Dictionary mapping file paths to their diff content + + Returns: + ReviewResult with aggregated suggestions + """ + start_time = time.time() + all_suggestions = [] + files_reviewed = 0 + + try: + for file_path, diff_content in pr_diff.items(): + result = self.review_diff(diff_content, file_path) + if result.status == ReviewStatus.COMPLETED: + all_suggestions.extend(result.suggestions) + files_reviewed += 1 + + # Group suggestions by category + suggestion_groups = self._group_suggestions(all_suggestions) + + duration = time.time() - start_time + + return ReviewResult( + status=ReviewStatus.COMPLETED, + suggestions=all_suggestions, + suggestion_groups=suggestion_groups, + files_reviewed=files_reviewed, + duration_seconds=duration, + ) + + except Exception as e: + duration = time.time() - start_time + return ReviewResult( + status=ReviewStatus.FAILED, + error=str(e), + duration_seconds=duration, + ) + + def _group_suggestions(self, suggestions: List[Suggestion]) -> List[SuggestionGroup]: + """ + Group suggestions by category. + + Args: + suggestions: List of suggestions to group + + Returns: + List of SuggestionGroup objects + """ + groups = {} + + for category in SuggestionCategory: + groups[category] = SuggestionGroup(category) + + for suggestion in suggestions: + if suggestion.category in groups: + groups[suggestion.category].add_suggestion(suggestion) + + # Return only non-empty groups + return [group for group in groups.values() if group.suggestions] + + def record_feedback(self, rule_id: str, accepted: bool) -> None: + """ + Record human feedback on a suggestion. + + Args: + rule_id: The rule ID that generated the suggestion + accepted: Whether the suggestion was accepted + """ + if accepted: + self.accepted_suggestions[rule_id] = ( + self.accepted_suggestions.get(rule_id, 0) + 1 + ) + else: + self.rejected_suggestions[rule_id] = ( + self.rejected_suggestions.get(rule_id, 0) + 1 + ) + + def get_rule_statistics(self) -> Dict[str, Dict[str, int]]: + """ + Get statistics on rule performance. + + Returns: + Dictionary mapping rule IDs to acceptance/rejection counts + """ + stats = {} + + for rule_id in set(list(self.accepted_suggestions.keys()) + list(self.rejected_suggestions.keys())): + stats[rule_id] = { + "accepted": self.accepted_suggestions.get(rule_id, 0), + "rejected": self.rejected_suggestions.get(rule_id, 0), + } + + return stats + + def calculate_accuracy(self) -> float: + """ + Calculate the accuracy of suggestions based on feedback. + + Returns: + Accuracy percentage (0-100) + """ + total_accepted = sum(self.accepted_suggestions.values()) + total_rejected = sum(self.rejected_suggestions.values()) + total = total_accepted + total_rejected + + if total == 0: + return 0.0 + + return (total_accepted / total) * 100 diff --git a/astroml/llm/code_review/suggestions.py b/astroml/llm/code_review/suggestions.py new file mode 100644 index 0000000..7703461 --- /dev/null +++ b/astroml/llm/code_review/suggestions.py @@ -0,0 +1,129 @@ +""" +Improvement suggestions for code review. + +This module defines the data structures for code review suggestions, +including categories, severity levels, and the suggestion format. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, List + + +class SuggestionCategory(Enum): + """Categories of code review suggestions.""" + + SECURITY = "Security" + PERFORMANCE = "Performance" + STYLE = "Style" + CORRECTNESS = "Correctness" + TESTING = "Testing" + DOCUMENTATION = "Documentation" + COMPLEXITY = "Complexity" + + +class SuggestionSeverity(Enum): + """Severity levels for code review suggestions.""" + + HIGH = "HIGH" + MEDIUM = "MEDIUM" + LOW = "LOW" + + +@dataclass +class Suggestion: + """ + A code review suggestion. + + Attributes: + category: The category of the suggestion (e.g., SECURITY, PERFORMANCE) + severity: The severity level (HIGH, MEDIUM, LOW) + message: The main issue description + file_path: Path to the file where the issue was found + line_number: Line number where the issue occurs + suggested_fix: Suggested fix with code snippet + context: Additional context about the issue + rule_id: Identifier for the rule that triggered this suggestion + """ + + category: SuggestionCategory + severity: SuggestionSeverity + message: str + file_path: str + line_number: int + suggested_fix: Optional[str] = None + context: Optional[str] = None + rule_id: Optional[str] = None + + def format_markdown(self) -> str: + """ + Format the suggestion as a markdown comment. + + Returns: + Formatted markdown string suitable for PR comments + """ + fix_text = f"\n - {self.suggested_fix}" if self.suggested_fix else "" + context_text = f"\n - Context: {self.context}" if self.context else "" + + return ( + f"- **[{self.severity.value}]** {self.message} in `{self.file_path}:{self.line_number}`" + f"{fix_text}{context_text}" + ) + + def to_dict(self) -> dict: + """Convert suggestion to dictionary representation.""" + return { + "category": self.category.value, + "severity": self.severity.value, + "message": self.message, + "file_path": self.file_path, + "line_number": self.line_number, + "suggested_fix": self.suggested_fix, + "context": self.context, + "rule_id": self.rule_id, + } + + +@dataclass +class SuggestionGroup: + """ + A group of suggestions organized by category. + + Attributes: + category: The category for this group + suggestions: List of suggestions in this category + """ + + category: SuggestionCategory + suggestions: List[Suggestion] = field(default_factory=list) + + def add_suggestion(self, suggestion: Suggestion) -> None: + """Add a suggestion to this group.""" + if suggestion.category != self.category: + raise ValueError( + f"Suggestion category {suggestion.category} does not match " + f"group category {self.category}" + ) + self.suggestions.append(suggestion) + + def format_markdown(self) -> str: + """ + Format the suggestion group as markdown. + + Returns: + Formatted markdown string + """ + if not self.suggestions: + return "" + + lines = [f"## {self.category.value}"] + for suggestion in self.suggestions: + lines.append(suggestion.format_markdown()) + return "\n".join(lines) + + def to_dict(self) -> dict: + """Convert suggestion group to dictionary representation.""" + return { + "category": self.category.value, + "suggestions": [s.to_dict() for s in self.suggestions], + } diff --git a/astroml/llm/docs/__init__.py b/astroml/llm/docs/__init__.py new file mode 100644 index 0000000..978e684 --- /dev/null +++ b/astroml/llm/docs/__init__.py @@ -0,0 +1,31 @@ +""" +LLM-powered documentation generation system for astroml. + +This module provides automated documentation generation capabilities including: +- API documentation from FastAPI endpoints +- Code documentation from docstrings and type hints +- Architecture documentation from code structure +- Tutorial generation from examples +- Changelog generation from git history +- README section generation +""" + +from astroml.llm.docs.generator import DocumentationGenerator +from astroml.llm.docs.code_analyzer import CodeAnalyzer, CodeElement +from astroml.llm.docs.writers import MarkdownWriter, RstWriter, HtmlWriter +from astroml.llm.docs.validator import DocumentationValidator, ValidationResult +from astroml.llm.docs.updater import DocumentationUpdater + +__all__ = [ + "DocumentationGenerator", + "CodeAnalyzer", + "CodeElement", + "MarkdownWriter", + "RstWriter", + "HtmlWriter", + "DocumentationValidator", + "ValidationResult", + "DocumentationUpdater", +] + +__version__ = "0.1.0" diff --git a/astroml/llm/docs/code_analyzer.py b/astroml/llm/docs/code_analyzer.py new file mode 100644 index 0000000..0814c12 --- /dev/null +++ b/astroml/llm/docs/code_analyzer.py @@ -0,0 +1,499 @@ +""" +Code analyzer for documentation generation. + +This module provides AST-based code analysis to extract structure, +docstrings, type hints, and other metadata for documentation generation. +""" + +import ast +import inspect +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import List, Dict, Optional, Any, Set +import importlib.util + + +class ElementType(Enum): + """Types of code elements.""" + + MODULE = "module" + CLASS = "class" + FUNCTION = "function" + METHOD = "method" + VARIABLE = "variable" + CONSTANT = "constant" + PROPERTY = "property" + DECORATOR = "decorator" + + +@dataclass +class CodeElement: + """ + Represents a code element extracted for documentation. + + Attributes: + name: Name of the element + element_type: Type of the element + docstring: Docstring content + file_path: Path to the file containing the element + line_number: Line number where element is defined + signature: Function/class signature + type_hints: Type hints for parameters and return + decorators: List of decorators + parameters: Function parameters + returns: Return type information + raises: Exceptions raised + examples: Code examples found in docstring + parent: Parent element (if nested) + children: Child elements + metadata: Additional metadata + """ + + name: str + element_type: ElementType + docstring: Optional[str] = None + file_path: Optional[str] = None + line_number: Optional[int] = None + signature: Optional[str] = None + type_hints: Dict[str, str] = field(default_factory=dict) + decorators: List[str] = field(default_factory=list) + parameters: List[Dict[str, Any]] = field(default_factory=list) + returns: Optional[str] = None + raises: List[str] = field(default_factory=list) + examples: List[str] = field(default_factory=list) + parent: Optional["CodeElement"] = None + children: List["CodeElement"] = field(default_factory=list) + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary representation.""" + return { + "name": self.name, + "element_type": self.element_type.value, + "docstring": self.docstring, + "file_path": self.file_path, + "line_number": self.line_number, + "signature": self.signature, + "type_hints": self.type_hints, + "decorators": self.decorators, + "parameters": self.parameters, + "returns": self.returns, + "raises": self.raises, + "examples": self.examples, + "metadata": self.metadata, + } + + +class CodeAnalyzer: + """ + Analyzes code structure and extracts documentation-relevant information. + + Uses AST parsing to accurately extract: + - Module structure + - Class definitions + - Function/method signatures + - Type hints + - Docstrings + - Decorators + - Inheritance relationships + """ + + def __init__(self): + """Initialize the code analyzer.""" + self.elements: List[CodeElement] = [] + self.current_module: Optional[CodeElement] = None + self.current_class: Optional[CodeElement] = None + + def analyze_file(self, file_path: str) -> List[CodeElement]: + """ + Analyze a Python file and extract code elements. + + Args: + file_path: Path to the Python file + + Returns: + List of CodeElement objects + """ + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + return self.analyze_content(content, file_path) + + def analyze_content(self, content: str, file_path: str = None) -> List[CodeElement]: + """ + Analyze Python code content and extract code elements. + + Args: + content: Python code content + file_path: Optional file path + + Returns: + List of CodeElement objects + """ + self.elements = [] + self.current_module = None + self.current_class = None + + try: + tree = ast.parse(content) + + # Create module element + module_docstring = ast.get_docstring(tree) + self.current_module = CodeElement( + name=Path(file_path).stem if file_path else "module", + element_type=ElementType.MODULE, + docstring=module_docstring, + file_path=file_path, + line_number=1, + ) + self.elements.append(self.current_module) + + # Visit AST nodes + visitor = DocumentationVisitor(self) + visitor.visit(tree) + + except SyntaxError as e: + print(f"Syntax error in {file_path}: {e}") + + return self.elements + + def analyze_directory( + self, directory_path: str, patterns: List[str] = None + ) -> List[CodeElement]: + """ + Analyze all Python files in a directory. + + Args: + directory_path: Path to the directory + patterns: File patterns to match (default: ["*.py"]) + + Returns: + List of all CodeElement objects + """ + if patterns is None: + patterns = ["*.py"] + + all_elements = [] + directory = Path(directory_path) + + for pattern in patterns: + for file_path in directory.rglob(pattern): + if file_path.is_file(): + elements = self.analyze_file(str(file_path)) + all_elements.extend(elements) + + return all_elements + + def extract_api_endpoints(self, file_path: str) -> List[Dict[str, Any]]: + """ + Extract FastAPI endpoint information from a file. + + Args: + file_path: Path to the file containing FastAPI routes + + Returns: + List of endpoint information dictionaries + """ + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + endpoints = [] + tree = ast.parse(content) + + class FastAPIVisitor(ast.NodeVisitor): + def __init__(self, endpoints_list): + self.endpoints = endpoints_list + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + # Check for FastAPI route decorators + for decorator in node.decorator_list: + if isinstance(decorator, ast.Call): + if isinstance(decorator.func, ast.Attribute): + if decorator.func.attr in [ + "get", + "post", + "put", + "delete", + "patch", + "options", + "head", + ]: + endpoint_info = { + "name": node.name, + "method": decorator.func.attr.upper(), + "path": self._extract_path(decorator), + "docstring": ast.get_docstring(node), + "line_number": node.lineno, + "parameters": self._extract_parameters(node), + "returns": self._extract_return_type(node), + } + self.endpoints.append(endpoint_info) + self.generic_visit(node) + + def _extract_path(self, decorator: ast.Call) -> str: + """Extract path from decorator.""" + if decorator.args: + if isinstance(decorator.args[0], ast.Str): + return decorator.args[0].s + elif isinstance(decorator.args[0], ast.Constant): + return str(decorator.args[0].value) + return "/" + + def _extract_parameters(self, node: ast.FunctionDef) -> List[Dict[str, str]]: + """Extract function parameters.""" + params = [] + for arg in node.args.args: + param_info = {"name": arg.arg, "type": None} + if arg.annotation: + param_info["type"] = ast.unparse(arg.annotation) + params.append(param_info) + return params + + def _extract_return_type(self, node: ast.FunctionDef) -> Optional[str]: + """Extract return type.""" + if node.returns: + return ast.unparse(node.returns) + return None + + visitor = FastAPIVisitor(endpoints) + visitor.visit(tree) + + return endpoints + + def extract_examples_from_tests( + self, test_file_path: str, source_file_path: str + ) -> List[str]: + """ + Extract code examples from test files. + + Args: + test_file_path: Path to the test file + source_file_path: Path to the corresponding source file + + Returns: + List of example code snippets + """ + examples = [] + + try: + with open(test_file_path, "r", encoding="utf-8") as f: + content = f.read() + + tree = ast.parse(content) + + class TestExampleVisitor(ast.NodeVisitor): + def __init__(self, examples_list): + self.examples = examples_list + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + if node.name.startswith("test_"): + # Extract function body as example + example_code = ast.unparse(node) + self.examples.append(example_code) + self.generic_visit(node) + + visitor = TestExampleVisitor(examples) + visitor.visit(tree) + + except Exception as e: + print(f"Error extracting examples from {test_file_path}: {e}") + + return examples + + def get_import_structure(self, file_path: str) -> Dict[str, List[str]]: + """ + Extract import structure from a file. + + Args: + file_path: Path to the file + + Returns: + Dictionary with 'standard', 'third_party', and 'local' imports + """ + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + imports = {"standard": [], "third_party": [], "local": []} + tree = ast.parse(content) + + class ImportVisitor(ast.NodeVisitor): + def __init__(self, imports_dict): + self.imports = imports_dict + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + module_name = alias.name.split(".")[0] + self._categorize_import(module_name) + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + if node.module: + module_name = node.module.split(".")[0] + self._categorize_import(module_name) + self.generic_visit(node) + + def _categorize_import(self, module_name: str) -> None: + """Categorize import by type.""" + standard_libs = { + "os", + "sys", + "re", + "json", + "datetime", + "typing", + "pathlib", + "collections", + "itertools", + "functools", + "math", + "random", + } + if module_name in standard_libs: + self.imports["standard"].append(module_name) + elif module_name.startswith("astroml"): + self.imports["local"].append(module_name) + else: + self.imports["third_party"].append(module_name) + + visitor = ImportVisitor(imports) + visitor.visit(tree) + + return imports + + +class DocumentationVisitor(ast.NodeVisitor): + """AST visitor for extracting documentation elements.""" + + def __init__(self, analyzer: CodeAnalyzer): + self.analyzer = analyzer + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + """Visit class definition.""" + class_element = CodeElement( + name=node.name, + element_type=ElementType.CLASS, + docstring=ast.get_docstring(node), + file_path=self.analyzer.current_module.file_path + if self.analyzer.current_module + else None, + line_number=node.lineno, + decorators=[ast.unparse(d) for d in node.decorator_list], + metadata={ + "bases": [ast.unparse(base) for base in node.bases], + }, + ) + + # Extract type hints from class variables + for stmt in node.body: + if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): + class_element.type_hints[stmt.target.id] = ast.unparse(stmt.annotation) + + if self.analyzer.current_module: + class_element.parent = self.analyzer.current_module + self.analyzer.current_module.children.append(class_element) + + # Set as current class and visit children + previous_class = self.analyzer.current_class + self.analyzer.current_class = class_element + self.analyzer.elements.append(class_element) + + self.generic_visit(node) + + self.analyzer.current_class = previous_class + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + """Visit function definition.""" + element_type = ( + ElementType.METHOD + if self.analyzer.current_class + else ElementType.FUNCTION + ) + + # Extract parameters + parameters = [] + for arg in node.args.args: + param_info = {"name": arg.arg} + if arg.annotation: + param_info["type"] = ast.unparse(arg.annotation) + if arg.arg in node.args.defaults: + param_info["default"] = ast.unparse( + node.args.defaults[node.args.args.index(arg)] + ) + parameters.append(param_info) + + # Extract return type + returns = ast.unparse(node.returns) if node.returns else None + + # Extract examples from docstring + docstring = ast.get_docstring(node) + examples = [] + if docstring: + examples = self._extract_examples(docstring) + + function_element = CodeElement( + name=node.name, + element_type=element_type, + docstring=docstring, + file_path=self.analyzer.current_module.file_path + if self.analyzer.current_module + else None, + line_number=node.lineno, + signature=ast.unparse(node), + decorators=[ast.unparse(d) for d in node.decorator_list], + parameters=parameters, + returns=returns, + examples=examples, + metadata={ + "is_async": isinstance(node, ast.AsyncFunctionDef), + "is_property": any( + "property" in ast.unparse(d) for d in node.decorator_list + ), + }, + ) + + # Extract type hints + for param in parameters: + if param.get("type"): + function_element.type_hints[param["name"]] = param["type"] + if returns: + function_element.type_hints["return"] = returns + + if self.analyzer.current_class: + function_element.parent = self.analyzer.current_class + self.analyzer.current_class.children.append(function_element) + elif self.analyzer.current_module: + function_element.parent = self.analyzer.current_module + self.analyzer.current_module.children.append(function_element) + + self.analyzer.elements.append(function_element) + self.generic_visit(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + """Visit async function definition.""" + self.visit_FunctionDef(node) + + def _extract_examples(self, docstring: str) -> List[str]: + """Extract code examples from docstring.""" + examples = [] + lines = docstring.split("\n") + in_example = False + example_lines = [] + + for line in lines: + if ">>>" in line or "Example:" in line: + in_example = True + example_lines.append(line) + elif in_example: + if line.strip() and not line.startswith(" "): + in_example = False + if example_lines: + examples.append("\n".join(example_lines)) + example_lines = [] + else: + example_lines.append(line) + + if example_lines: + examples.append("\n".join(example_lines)) + + return examples diff --git a/astroml/llm/docs/generator.py b/astroml/llm/docs/generator.py new file mode 100644 index 0000000..e0101db --- /dev/null +++ b/astroml/llm/docs/generator.py @@ -0,0 +1,545 @@ +""" +Documentation generation orchestrator. + +This module provides the main documentation generation functionality, +coordinating code analysis, writing, validation, and updating. +""" + +import time +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import List, Dict, Optional, Set + +from astroml.llm.docs.code_analyzer import CodeAnalyzer, CodeElement, ElementType +from astroml.llm.docs.writers import ( + BaseWriter, + MarkdownWriter, + RstWriter, + HtmlWriter, + WriterConfig, +) +from astroml.llm.docs.validator import DocumentationValidator, ValidationResult +from astroml.llm.docs.updater import DocumentationUpdater, UpdateResult + + +class DocType(Enum): + """Types of documentation to generate.""" + + API = "api" + CODE = "code" + ARCHITECTURE = "architecture" + TUTORIAL = "tutorial" + CHANGELOG = "changelog" + README = "README" + + +class OutputFormat(Enum): + """Output formats for documentation.""" + + MARKDOWN = "markdown" + RST = "rst" + HTML = "html" + + +@dataclass +class GenerationConfig: + """ + Configuration for documentation generation. + + Attributes: + doc_type: Type of documentation to generate + output_format: Output format + output_dir: Directory to write documentation + include_private: Include private members + include_internal: Include internal members + include_examples: Include code examples + include_type_hints: Include type hints + validate_after_generation: Validate generated docs + update_existing: Update existing documentation + preserve_manual_edits: Preserve manual edits when updating + """ + + doc_type: DocType = DocType.CODE + output_format: OutputFormat = OutputFormat.MARKDOWN + output_dir: str = "docs" + include_private: bool = False + include_internal: bool = False + include_examples: bool = True + include_type_hints: bool = True + validate_after_generation: bool = True + update_existing: bool = False + preserve_manual_edits: bool = True + + +@dataclass +class GenerationResult: + """ + Result of documentation generation. + + Attributes: + success: Whether generation was successful + files_generated: List of generated file paths + files_updated: List of updated file paths + validation_result: Validation result if validation was performed + duration_seconds: Time taken for generation + error: Error message if generation failed + """ + + success: bool + files_generated: List[str] = field(default_factory=list) + files_updated: List[str] = field(default_factory=list) + validation_result: Optional[ValidationResult] = None + duration_seconds: float = 0.0 + error: Optional[str] = None + + +class DocumentationGenerator: + """ + Main documentation generation orchestrator. + + Coordinates: + - Code analysis + - Documentation writing + - Validation + - Updates + """ + + def __init__(self, config: GenerationConfig = None): + """ + Initialize the documentation generator. + + Args: + config: Generation configuration + """ + self.config = config or GenerationConfig() + self.analyzer = CodeAnalyzer() + self.validator = DocumentationValidator() + self.updater = DocumentationUpdater() + + def generate_from_directory( + self, source_dir: str, output_dir: str = None + ) -> GenerationResult: + """ + Generate documentation from a directory of source files. + + Args: + source_dir: Directory containing source files + output_dir: Optional output directory (overrides config) + + Returns: + GenerationResult with generation status + """ + start_time = time.time() + result = GenerationResult(success=True) + + try: + output_path = Path(output_dir or self.config.output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + # Analyze source code + elements = self.analyzer.analyze_directory(source_dir) + + if not elements: + result.success = False + result.error = "No code elements found in source directory" + return result + + # Select appropriate writer + writer = self._get_writer() + + # Generate documentation + output_file = output_path / f"{self.config.doc_type.value}.{self._get_extension()}" + writer.write(elements, str(output_file)) + result.files_generated.append(str(output_file)) + + # Validate if configured + if self.config.validate_after_generation: + result.validation_result = self.validator.validate_documentation( + str(output_file), elements + ) + + result.duration_seconds = time.time() - start_time + + except Exception as e: + result.success = False + result.error = str(e) + result.duration_seconds = time.time() - start_time + + return result + + def generate_from_file( + self, source_file: str, output_file: str = None + ) -> GenerationResult: + """ + Generate documentation from a single source file. + + Args: + source_file: Path to the source file + output_file: Optional output file path + + Returns: + GenerationResult with generation status + """ + start_time = time.time() + result = GenerationResult(success=True) + + try: + # Analyze source code + elements = self.analyzer.analyze_file(source_file) + + if not elements: + result.success = False + result.error = "No code elements found in source file" + return result + + # Determine output path + if output_file is None: + output_path = ( + Path(self.config.output_dir) + / f"{Path(source_file).stem}.{self._get_extension()}" + ) + else: + output_path = Path(output_file) + + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Select appropriate writer + writer = self._get_writer() + + # Generate documentation + writer.write(elements, str(output_path)) + result.files_generated.append(str(output_path)) + + # Validate if configured + if self.config.validate_after_generation: + result.validation_result = self.validator.validate_documentation( + str(output_path), elements + ) + + result.duration_seconds = time.time() - start_time + + except Exception as e: + result.success = False + result.error = str(e) + result.duration_seconds = time.time() - start_time + + return result + + def generate_api_docs( + self, api_file: str, output_file: str = None + ) -> GenerationResult: + """ + Generate API documentation from FastAPI routes. + + Args: + api_file: Path to the file containing FastAPI routes + output_file: Optional output file path + + Returns: + GenerationResult with generation status + """ + start_time = time.time() + result = GenerationResult(success=True) + + try: + # Extract API endpoints + endpoints = self.analyzer.extract_api_endpoints(api_file) + + if not endpoints: + result.success = False + result.error = "No API endpoints found in file" + return result + + # Generate API documentation + output_path = Path(output_file or self.config.output_dir) / "api.md" + output_path.parent.mkdir(parents=True, exist_ok=True) + + content = self._generate_api_content(endpoints, api_file) + output_path.write_text(content, encoding="utf-8") + + result.files_generated.append(str(output_path)) + result.duration_seconds = time.time() - start_time + + except Exception as e: + result.success = False + result.error = str(e) + result.duration_seconds = time.time() - start_time + + return result + + def generate_changelog( + self, repo_path: str, output_file: str = None + ) -> GenerationResult: + """ + Generate changelog from git history. + + Args: + repo_path: Path to the git repository + output_file: Optional output file path + + Returns: + GenerationResult with generation status + """ + start_time = time.time() + result = GenerationResult(success=True) + + try: + # This would integrate with git to extract commit history + # For now, return a placeholder result + output_path = Path(output_file or self.config.output_dir) / "CHANGELOG.md" + output_path.parent.mkdir(parents=True, exist_ok=True) + + content = "# Changelog\n\nThis changelog is automatically generated from git history.\n" + output_path.write_text(content, encoding="utf-8") + + result.files_generated.append(str(output_path)) + result.duration_seconds = time.time() - start_time + + except Exception as e: + result.success = False + result.error = str(e) + result.duration_seconds = time.time() - start_time + + return result + + def generate_readme_sections( + self, project_root: str, output_file: str = None + ) -> GenerationResult: + """ + Generate README sections from project structure. + + Args: + project_root: Path to the project root + output_file: Optional output file path + + Returns: + GenerationResult with generation status + """ + start_time = time.time() + result = GenerationResult(success=True) + + try: + project_path = Path(project_root) + + # Analyze project structure + sections = { + "Installation": self._generate_installation_section(project_path), + "Usage": self._generate_usage_section(project_path), + "Project Structure": self._generate_structure_section(project_path), + } + + # Generate README + output_path = Path(output_file or project_root) / "README.md" + existing_content = "" + + if output_path.exists(): + existing_content = output_path.read_text(encoding="utf-8") + + content = self._merge_readme_sections(existing_content, sections) + output_path.write_text(content, encoding="utf-8") + + result.files_generated.append(str(output_path)) + result.duration_seconds = time.time() - start_time + + except Exception as e: + result.success = False + result.error = str(e) + result.duration_seconds = time.time() - start_time + + return result + + def update_documentation( + self, doc_path: str, source_paths: List[str] + ) -> GenerationResult: + """ + Update existing documentation. + + Args: + doc_path: Path to the documentation file + source_paths: List of source file paths + + Returns: + GenerationResult with update status + """ + start_time = time.time() + result = GenerationResult(success=True) + + try: + update_result = self.updater.update_documentation( + doc_path, + source_paths, + preserve_manual_edits=self.config.preserve_manual_edits, + ) + + result.success = update_result.success + result.files_updated = update_result.updated_files + result.files_generated = update_result.updated_files + result.error = "\n".join(update_result.errors) if update_result.errors else None + + result.duration_seconds = time.time() - start_time + + except Exception as e: + result.success = False + result.error = str(e) + result.duration_seconds = time.time() - start_time + + return result + + def _get_writer(self) -> BaseWriter: + """Get the appropriate writer based on configuration.""" + config = WriterConfig( + include_private=self.config.include_private, + include_internal=self.config.include_internal, + include_examples=self.config.include_examples, + include_type_hints=self.config.include_type_hints, + ) + + if self.config.output_format == OutputFormat.MARKDOWN: + return MarkdownWriter(config) + elif self.config.output_format == OutputFormat.RST: + return RstWriter(config) + elif self.config.output_format == OutputFormat.HTML: + return HtmlWriter(config) + else: + raise ValueError(f"Unsupported output format: {self.config.output_format}") + + def _get_extension(self) -> str: + """Get file extension for the output format.""" + extensions = { + OutputFormat.MARKDOWN: "md", + OutputFormat.RST: "rst", + OutputFormat.HTML: "html", + } + return extensions.get(self.config.output_format, "md") + + def _generate_api_content(self, endpoints: List[Dict], api_file: str) -> str: + """Generate API documentation content.""" + lines = ["# API Documentation\n"] + lines.append(f"Generated from `{api_file}`\n") + + # Group by path + paths = {} + for endpoint in endpoints: + path = endpoint["path"] + if path not in paths: + paths[path] = [] + paths[path].append(endpoint) + + # Generate documentation for each path + for path in sorted(paths.keys()): + lines.append(f"## {path}\n") + + for endpoint in paths[path]: + lines.append(f"### {endpoint['method']} {endpoint['name']}\n") + + if endpoint.get("docstring"): + lines.append(endpoint["docstring"]) + lines.append("") + + if endpoint.get("parameters"): + lines.append("**Parameters:**") + for param in endpoint["parameters"]: + param_line = f"- `{param['name']}`" + if param.get("type"): + param_line += f": `{param['type']}`" + lines.append(param_line) + lines.append("") + + if endpoint.get("returns"): + lines.append(f"**Returns:** `{endpoint['returns']}`") + lines.append("") + + return "\n".join(lines) + + def _generate_installation_section(self, project_path: Path) -> str: + """Generate installation section.""" + lines = ["## Installation\n"] + + # Check for requirements.txt + if (project_path / "requirements.txt").exists(): + lines.append("```bash") + lines.append("pip install -r requirements.txt") + lines.append("```") + elif (project_path / "pyproject.toml").exists(): + lines.append("```bash") + lines.append("pip install -e .") + lines.append("```") + else: + lines.append("```bash") + lines.append("pip install astroml") + lines.append("```") + + lines.append("") + return "\n".join(lines) + + def _generate_usage_section(self, project_path: Path) -> str: + """Generate usage section.""" + lines = ["## Usage\n"] + + # Look for examples directory + examples_dir = project_path / "examples" + if examples_dir.exists(): + lines.append("See the [examples](examples/) directory for usage examples.\n") + else: + lines.append("```python") + lines.append("import astroml") + lines.append("") + lines.append("# Your code here") + lines.append("```") + + lines.append("") + return "\n".join(lines) + + def _generate_structure_section(self, project_path: Path) -> str: + """Generate project structure section.""" + lines = ["## Project Structure\n"] + lines.append("```") + + # Generate tree structure + def generate_tree(path: Path, prefix: str = ""): + items = sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name)) + for i, item in enumerate(items): + is_last = i == len(items) - 1 + connector = "└── " if is_last else "ā”œā”€ā”€ " + lines.append(f"{prefix}{connector}{item.name}") + if item.is_dir() and not item.name.startswith("."): + new_prefix = prefix + (" " if is_last else "│ ") + generate_tree(item, new_prefix) + + generate_tree(project_path) + lines.append("```") + lines.append("") + return "\n".join(lines) + + def _merge_readme_sections( + self, existing_content: str, new_sections: Dict[str, str] + ) -> str: + """Merge new sections into existing README.""" + lines = existing_content.split("\n") + merged = [] + skip_until = None + + for line in lines: + if skip_until: + if line.startswith(skip_until): + skip_until = None + continue + + section_found = False + for section_name in new_sections.keys(): + if line.startswith(f"## {section_name}"): + merged.append(new_sections[section_name]) + skip_until = f"## " + section_found = True + break + + if not section_found: + merged.append(line) + + # Add any sections that weren't found + for section_name, section_content in new_sections.items(): + if f"## {section_name}" not in existing_content: + merged.append(section_content) + + return "\n".join(merged) diff --git a/astroml/llm/docs/updater.py b/astroml/llm/docs/updater.py new file mode 100644 index 0000000..a319416 --- /dev/null +++ b/astroml/llm/docs/updater.py @@ -0,0 +1,405 @@ +""" +Documentation updater for maintaining sync with code. + +This module provides functionality to update documentation when code changes, +detecting outdated docs and applying updates while preserving manual edits. +""" + +import hashlib +import json +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Set +import difflib + +from astroml.llm.docs.code_analyzer import CodeAnalyzer, CodeElement + + +@dataclass +class DocMetadata: + """ + Metadata for documentation files. + + Attributes: + file_path: Path to the documentation file + source_files: List of source files used to generate the doc + hash: Hash of the source files for change detection + generated_at: Timestamp when documentation was generated + last_updated: Timestamp when documentation was last updated + manual_edits: Flag indicating if manual edits were made + checksum: Checksum of the documentation content + """ + + file_path: str + source_files: List[str] = field(default_factory=list) + hash: str = "" + generated_at: str = "" + last_updated: str = "" + manual_edits: bool = False + checksum: str = "" + + def to_dict(self) -> Dict: + """Convert to dictionary.""" + return { + "file_path": self.file_path, + "source_files": self.source_files, + "hash": self.hash, + "generated_at": self.generated_at, + "last_updated": self.last_updated, + "manual_edits": self.manual_edits, + "checksum": self.checksum, + } + + @classmethod + def from_dict(cls, data: Dict) -> "DocMetadata": + """Create from dictionary.""" + return cls(**data) + + +@dataclass +class UpdateResult: + """ + Result of a documentation update operation. + + Attributes: + success: Whether the update was successful + updated_files: List of files that were updated + skipped_files: List of files that were skipped + errors: List of errors encountered + changes_made: Summary of changes made + """ + + success: bool + updated_files: List[str] = field(default_factory=list) + skipped_files: List[str] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + changes_made: str = "" + + +class DocumentationUpdater: + """ + Updates documentation to stay in sync with code changes. + + Features: + - Detects when source code has changed + - Preserves manual edits in documentation + - Applies incremental updates + - Maintains metadata for change tracking + - Handles merge conflicts + """ + + def __init__(self, metadata_dir: str = ".doc_metadata"): + """ + Initialize the documentation updater. + + Args: + metadata_dir: Directory to store documentation metadata + """ + self.metadata_dir = Path(metadata_dir) + self.metadata_dir.mkdir(exist_ok=True) + self.analyzer = CodeAnalyzer() + + def update_documentation( + self, + doc_path: str, + source_paths: List[str], + preserve_manual_edits: bool = True, + ) -> UpdateResult: + """ + Update documentation based on source code changes. + + Args: + doc_path: Path to the documentation file + source_paths: List of source file paths + preserve_manual_edits: Whether to preserve manual edits + + Returns: + UpdateResult with update status + """ + result = UpdateResult(success=True) + + try: + # Load existing metadata + metadata = self._load_metadata(doc_path) + + # Calculate current hash of source files + current_hash = self._calculate_source_hash(source_paths) + + # Check if update is needed + if metadata and metadata.hash == current_hash: + result.skipped_files.append(doc_path) + result.changes_made = "No changes detected in source files" + return result + + # Analyze source code + all_elements = [] + for source_path in source_paths: + elements = self.analyzer.analyze_file(source_path) + all_elements.extend(elements) + + # Read existing documentation if it exists + existing_doc = "" + if Path(doc_path).exists(): + with open(doc_path, "r", encoding="utf-8") as f: + existing_doc = f.read() + + # Generate new documentation + new_doc = self._generate_documentation(all_elements, existing_doc) + + # Check for manual edits if preserving + if preserve_manual_edits and metadata and metadata.manual_edits: + new_doc = self._merge_manual_edits(existing_doc, new_doc) + + # Write updated documentation + Path(doc_path).parent.mkdir(parents=True, exist_ok=True) + with open(doc_path, "w", encoding="utf-8") as f: + f.write(new_doc) + + # Update metadata + new_metadata = DocMetadata( + file_path=doc_path, + source_files=source_paths, + hash=current_hash, + generated_at=datetime.now().isoformat(), + last_updated=datetime.now().isoformat(), + manual_edits=False, + checksum=self._calculate_checksum(new_doc), + ) + self._save_metadata(new_metadata) + + result.updated_files.append(doc_path) + result.changes_made = f"Updated documentation from {len(source_paths)} source file(s)" + + except Exception as e: + result.success = False + result.errors.append(str(e)) + + return result + + def detect_outdated_docs(self, doc_dir: str) -> List[str]: + """ + Detect documentation files that are outdated. + + Args: + doc_dir: Directory containing documentation files + + Returns: + List of outdated documentation file paths + """ + outdated = [] + + for metadata_file in self.metadata_dir.glob("*.json"): + try: + with open(metadata_file, "r") as f: + metadata = DocMetadata.from_dict(json.load(f)) + + # Check if source files still exist + source_exists = all(Path(sf).exists() for sf in metadata.source_files) + if not source_exists: + outdated.append(metadata.file_path) + continue + + # Check if source files have changed + current_hash = self._calculate_source_hash(metadata.source_files) + if metadata.hash != current_hash: + outdated.append(metadata.file_path) + + except Exception as e: + print(f"Error checking metadata {metadata_file}: {e}") + + return outdated + + def batch_update( + self, + mappings: Dict[str, List[str]], + preserve_manual_edits: bool = True, + ) -> UpdateResult: + """ + Update multiple documentation files. + + Args: + mappings: Dictionary mapping doc paths to source file lists + preserve_manual_edits: Whether to preserve manual edits + + Returns: + UpdateResult with batch update status + """ + result = UpdateResult(success=True) + + for doc_path, source_paths in mappings.items(): + update_result = self.update_documentation( + doc_path, source_paths, preserve_manual_edits + ) + + result.updated_files.extend(update_result.updated_files) + result.skipped_files.extend(update_result.skipped_files) + result.errors.extend(update_result.errors) + + result.success = len(result.errors) == 0 + result.changes_made = f"Updated {len(result.updated_files)} file(s), skipped {len(result.skipped_files)}" + + return result + + def mark_manual_edits(self, doc_path: str) -> None: + """ + Mark a documentation file as having manual edits. + + Args: + doc_path: Path to the documentation file + """ + metadata = self._load_metadata(doc_path) + if metadata: + metadata.manual_edits = True + metadata.last_updated = datetime.now().isoformat() + metadata.checksum = self._calculate_checksum( + Path(doc_path).read_text(encoding="utf-8") + ) + self._save_metadata(metadata) + + def _load_metadata(self, doc_path: str) -> Optional[DocMetadata]: + """Load metadata for a documentation file.""" + metadata_file = self.metadata_dir / f"{Path(doc_path).stem}.json" + + if not metadata_file.exists(): + return None + + try: + with open(metadata_file, "r") as f: + return DocMetadata.from_dict(json.load(f)) + except Exception: + return None + + def _save_metadata(self, metadata: DocMetadata) -> None: + """Save metadata for a documentation file.""" + metadata_file = self.metadata_dir / f"{Path(metadata.file_path).stem}.json" + + with open(metadata_file, "w") as f: + json.dump(metadata.to_dict(), f, indent=2) + + def _calculate_source_hash(self, source_paths: List[str]) -> str: + """Calculate hash of source files.""" + hasher = hashlib.sha256() + + for source_path in sorted(source_paths): + if Path(source_path).exists(): + with open(source_path, "rb") as f: + hasher.update(f.read()) + + return hasher.hexdigest() + + def _calculate_checksum(self, content: str) -> str: + """Calculate checksum of content.""" + return hashlib.md5(content.encode()).hexdigest() + + def _generate_documentation( + self, elements: List[CodeElement], existing_doc: str = "" + ) -> str: + """ + Generate documentation from code elements. + + Args: + elements: List of code elements + existing_doc: Existing documentation content + + Returns: + Generated documentation string + """ + # Simple generation - in production, use writers + lines = [] + + for element in elements: + if element.element_type.value == "module": + lines.append(f"# {element.name}") + if element.docstring: + lines.append(element.docstring) + lines.append("") + + elif element.element_type.value == "class": + lines.append(f"## {element.name}") + if element.docstring: + lines.append(element.docstring) + lines.append("") + + elif element.element_type.value in ["function", "method"]: + lines.append(f"### {element.name}") + if element.signature: + lines.append("```python") + lines.append(element.signature) + lines.append("```") + if element.docstring: + lines.append(element.docstring) + lines.append("") + + return "\n".join(lines) + + def _merge_manual_edits(self, existing_doc: str, new_doc: str) -> str: + """ + Merge manual edits from existing documentation into new documentation. + + Args: + existing_doc: Existing documentation with manual edits + new_doc: Newly generated documentation + + Returns: + Merged documentation + """ + # Simple strategy: preserve sections that exist in both + # In production, use more sophisticated diff/merge algorithms + + existing_lines = existing_doc.split("\n") + new_lines = new_doc.split("\n") + + # Use difflib to find common sections + matcher = difflib.SequenceMatcher(None, existing_lines, new_lines) + + merged = [] + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + # Keep common sections from new doc + merged.extend(new_lines[j1:j2]) + elif tag == "replace": + # Keep new version + merged.extend(new_lines[j1:j2]) + elif tag == "delete": + # Skip deleted sections + pass + elif tag == "insert": + # Add new sections + merged.extend(new_lines[j1:j2]) + + return "\n".join(merged) + + def get_diff(self, old_doc: str, new_doc: str) -> str: + """ + Get diff between old and new documentation. + + Args: + old_doc: Old documentation content + new_doc: New documentation content + + Returns: + Unified diff string + """ + old_lines = old_doc.split("\n") + new_lines = new_doc.split("\n") + + diff = difflib.unified_diff( + old_lines, new_lines, lineterm="", fromfile="old", tofile="new" + ) + + return "\n".join(diff) + + def rollback_update(self, doc_path: str) -> bool: + """ + Rollback a documentation update to previous version. + + Args: + doc_path: Path to the documentation file + + Returns: + Whether rollback was successful + """ + # In production, implement version control integration + # For now, this is a placeholder + return False diff --git a/astroml/llm/docs/validator.py b/astroml/llm/docs/validator.py new file mode 100644 index 0000000..78c4720 --- /dev/null +++ b/astroml/llm/docs/validator.py @@ -0,0 +1,453 @@ +""" +Documentation quality validator. + +This module provides validation capabilities for generated documentation, +including link checking, example validation, completeness scoring, and +readability metrics. +""" + +import re +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import List, Dict, Optional, Tuple +import ast + + +class ValidationSeverity(Enum): + """Severity levels for validation issues.""" + + ERROR = "error" + WARNING = "warning" + INFO = "info" + + +@dataclass +class ValidationIssue: + """ + Represents a validation issue found in documentation. + + Attributes: + severity: Severity level of the issue + message: Description of the issue + location: Location in the documentation (file, line) + suggestion: Suggested fix + """ + + severity: ValidationSeverity + message: str + location: str + suggestion: Optional[str] = None + + +@dataclass +class ValidationResult: + """ + Result of documentation validation. + + Attributes: + is_valid: Overall validity status + issues: List of validation issues + completeness_score: Completeness score (0-100) + readability_score: Readability score (0-100) + broken_links: List of broken links + invalid_examples: List of invalid code examples + """ + + is_valid: bool + issues: List[ValidationIssue] = field(default_factory=list) + completeness_score: float = 0.0 + readability_score: float = 0.0 + broken_links: List[str] = field(default_factory=list) + invalid_examples: List[str] = field(default_factory=list) + + def add_issue( + self, + severity: ValidationSeverity, + message: str, + location: str, + suggestion: Optional[str] = None, + ) -> None: + """Add a validation issue.""" + self.issues.append( + ValidationIssue(severity=severity, message=message, location=location, suggestion=suggestion) + ) + + def get_summary(self) -> str: + """Get a summary of validation results.""" + error_count = sum(1 for i in self.issues if i.severity == ValidationSeverity.ERROR) + warning_count = sum(1 for i in self.issues if i.severity == ValidationSeverity.WARNING) + info_count = sum(1 for i in self.issues if i.severity == ValidationSeverity.INFO) + + summary = f"Validation Results:\n" + summary += f"- Valid: {self.is_valid}\n" + summary += f"- Completeness Score: {self.completeness_score:.1f}/100\n" + summary += f"- Readability Score: {self.readability_score:.1f}/100\n" + summary += f"- Errors: {error_count}\n" + summary += f"- Warnings: {warning_count}\n" + summary += f"- Info: {info_count}\n" + summary += f"- Broken Links: {len(self.broken_links)}\n" + summary += f"- Invalid Examples: {len(self.invalid_examples)}\n" + + return summary + + +class DocumentationValidator: + """ + Validator for documentation quality. + + Performs various quality checks: + - Broken link detection + - Code example validation + - Completeness scoring + - Readability metrics + - Consistency checks + """ + + def __init__(self, base_url: Optional[str] = None): + """ + Initialize the validator. + + Args: + base_url: Base URL for link validation + """ + self.base_url = base_url + + def validate_documentation( + self, doc_path: str, code_elements: List = None + ) -> ValidationResult: + """ + Validate documentation file. + + Args: + doc_path: Path to the documentation file + code_elements: Optional list of code elements for consistency checks + + Returns: + ValidationResult with validation findings + """ + result = ValidationResult(is_valid=True) + + with open(doc_path, "r", encoding="utf-8") as f: + content = f.read() + + # Check for broken links + broken_links = self._check_links(content, doc_path) + result.broken_links.extend(broken_links) + for link in broken_links: + result.add_issue( + severity=ValidationSeverity.WARNING, + message=f"Broken or invalid link: {link}", + location=doc_path, + suggestion="Verify the link is correct and accessible", + ) + + # Validate code examples + invalid_examples = self._validate_examples(content, doc_path) + result.invalid_examples.extend(invalid_examples) + for example in invalid_examples: + result.add_issue( + severity=ValidationSeverity.ERROR, + message=f"Invalid code example", + location=doc_path, + suggestion="Fix syntax errors in the code example", + ) + + # Calculate completeness score + result.completeness_score = self._calculate_completeness(content, code_elements) + + # Calculate readability score + result.readability_score = self._calculate_readability(content) + + # Check for consistency with code + if code_elements: + consistency_issues = self._check_consistency(content, code_elements, doc_path) + for issue in consistency_issues: + result.add_issue(**issue) + + # Determine overall validity + result.is_valid = ( + len(result.broken_links) == 0 + and len(result.invalid_examples) == 0 + and all(i.severity != ValidationSeverity.ERROR for i in result.issues) + ) + + return result + + def _check_links(self, content: str, doc_path: str) -> List[str]: + """ + Check for broken or invalid links in documentation. + + Args: + content: Documentation content + doc_path: Path to the documentation file + + Returns: + List of broken links + """ + broken_links = [] + + # Extract markdown links + markdown_links = re.findall(r'\[([^\]]+)\]\(([^)]+)\)', content) + for text, url in markdown_links: + # Check for local file references + if url.startswith("./") or url.startswith("../"): + local_path = Path(doc_path).parent / url + if not local_path.exists(): + broken_links.append(url) + + # Check for http/https links (basic validation) + elif url.startswith(("http://", "https://")): + if not re.match(r'^https?://[^\s/$.?#].[^\s]*$', url): + broken_links.append(url) + + return broken_links + + def _validate_examples(self, content: str, doc_path: str) -> List[str]: + """ + Validate code examples in documentation. + + Args: + content: Documentation content + doc_path: Path to the documentation file + + Returns: + List of invalid examples + """ + invalid_examples = [] + + # Extract code blocks + code_blocks = re.findall(r'```python\n(.*?)\n```', content, re.DOTALL) + + for code in code_blocks: + try: + ast.parse(code) + except SyntaxError: + invalid_examples.append(code[:50] + "...") + + return invalid_examples + + def _calculate_completeness(self, content: str, code_elements: List = None) -> float: + """ + Calculate completeness score for documentation. + + Args: + content: Documentation content + code_elements: Optional code elements to compare against + + Returns: + Completeness score (0-100) + """ + score = 0.0 + total_checks = 0 + + # Check for title + if re.search(r'^#\s+.+$', content, re.MULTILINE): + score += 10 + total_checks += 1 + + # Check for description + if len(content) > 100: + score += 10 + total_checks += 1 + + # Check for code examples + if re.search(r'```', content): + score += 20 + total_checks += 1 + + # Check for parameter documentation + if re.search(r'parameter|arg|argument', content, re.IGNORECASE): + score += 15 + total_checks += 1 + + # Check for return value documentation + if re.search(r'return|returns', content, re.IGNORECASE): + score += 15 + total_checks += 1 + + # Check for exception documentation + if re.search(r'raise|raises|exception', content, re.IGNORECASE): + score += 10 + total_checks += 1 + + # Check for type information + if re.search(r'type|:py:class:|:py:data:', content, re.IGNORECASE): + score += 10 + total_checks += 1 + + # Check for links + if re.search(r'\[.*\]\(.*\)', content): + score += 10 + total_checks += 1 + + return (score / total_checks * 100) if total_checks > 0 else 0.0 + + def _calculate_readability(self, content: str) -> float: + """ + Calculate readability score for documentation. + + Args: + content: Documentation content + + Returns: + Readability score (0-100) + """ + score = 0.0 + total_checks = 0 + + # Check sentence length (average) + sentences = re.split(r'[.!?]+', content) + sentences = [s.strip() for s in sentences if s.strip()] + if sentences: + avg_length = sum(len(s.split()) for s in sentences) / len(sentences) + if avg_length < 25: # Good sentence length + score += 20 + elif avg_length < 35: + score += 10 + total_checks += 1 + + # Check paragraph length + paragraphs = content.split('\n\n') + paragraphs = [p.strip() for p in paragraphs if p.strip()] + if paragraphs: + avg_para_length = sum(len(p.split()) for p in paragraphs) / len(paragraphs) + if avg_para_length < 100: # Good paragraph length + score += 20 + elif avg_para_length < 150: + score += 10 + total_checks += 1 + + # Check for excessive jargon + jargon_words = ['implement', 'utilize', 'leverage', 'facilitate', 'optimize'] + jargon_count = sum(1 for word in jargon_words if word in content.lower()) + if jargon_count < 3: + score += 20 + elif jargon_count < 5: + score += 10 + total_checks += 1 + + # Check for active voice (simple heuristic) + passive_indicators = ['is used', 'are used', 'was used', 'were used'] + passive_count = sum(1 for indicator in passive_indicators if indicator in content.lower()) + if passive_count < 2: + score += 20 + elif passive_count < 4: + score += 10 + total_checks += 1 + + # Check for formatting consistency + if re.search(r'#{1,6}\s', content): # Has headers + score += 20 + total_checks += 1 + + return (score / total_checks * 100) if total_checks > 0 else 0.0 + + def _check_consistency( + self, content: str, code_elements: List, doc_path: str + ) -> List[Dict]: + """ + Check consistency between documentation and code. + + Args: + content: Documentation content + code_elements: List of code elements + doc_path: Path to documentation + + Returns: + List of consistency issues + """ + issues = [] + + if not code_elements: + return issues + + # Check if all public elements are documented + documented_names = set(re.findall(r'#{1,6}\s+([^\n]+)', content)) + + for element in code_elements: + if not element.name.startswith("_"): # Public element + if element.name not in documented_names: + issues.append({ + "severity": ValidationSeverity.WARNING, + "message": f"Public element '{element.name}' not documented", + "location": doc_path, + "suggestion": f"Add documentation for {element.name}", + }) + + # Check for outdated signatures + for element in code_elements: + if element.signature and element.element_type.value in ["function", "method"]: + # Check if signature is mentioned in docs + if element.name in content: + # Simple check: does the doc mention the function name + pass # More sophisticated checking could be added + + return issues + + def validate_example_runnable(self, code: str) -> Tuple[bool, Optional[str]]: + """ + Validate that a code example is runnable. + + Args: + code: Code example to validate + + Returns: + Tuple of (is_valid, error_message) + """ + try: + # Check syntax + ast.parse(code) + + # Check for common issues + if "TODO" in code or "FIXME" in code: + return False, "Example contains TODO/FIXME comments" + + if "import" not in code: + return False, "Example may be missing imports" + + return True, None + + except SyntaxError as e: + return False, f"Syntax error: {str(e)}" + + def check_outdated_docs( + self, doc_path: str, code_path: str + ) -> List[ValidationIssue]: + """ + Check if documentation is outdated compared to code. + + Args: + doc_path: Path to documentation + code_path: Path to corresponding code + + Returns: + List of outdated documentation issues + """ + issues = [] + + try: + doc_mtime = Path(doc_path).stat().st_mtime + code_mtime = Path(code_path).stat().st_mtime + + if code_mtime > doc_mtime: + issues.append( + ValidationIssue( + severity=ValidationSeverity.WARNING, + message=f"Documentation may be outdated (code modified since doc generation)", + location=doc_path, + suggestion="Regenerate documentation from latest code", + ) + ) + + except FileNotFoundError: + issues.append( + ValidationIssue( + severity=ValidationSeverity.ERROR, + message=f"File not found: {code_path or doc_path}", + location=doc_path, + suggestion="Ensure both code and documentation files exist", + ) + ) + + return issues diff --git a/astroml/llm/docs/writers.py b/astroml/llm/docs/writers.py new file mode 100644 index 0000000..1cabc25 --- /dev/null +++ b/astroml/llm/docs/writers.py @@ -0,0 +1,468 @@ +""" +Documentation writers for different output formats. + +This module provides writers for generating documentation in various formats: +- Markdown (MD) +- reStructuredText (RST) +- HTML +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from typing import List, Dict, Optional + +from astroml.llm.docs.code_analyzer import CodeElement, ElementType + + +@dataclass +class WriterConfig: + """Configuration for documentation writers.""" + + include_private: bool = False + include_internal: bool = False + include_examples: bool = True + include_type_hints: bool = True + include_source_links: bool = True + base_url: Optional[str] = None + toc_depth: int = 3 + + +class BaseWriter(ABC): + """Base class for documentation writers.""" + + def __init__(self, config: WriterConfig = None): + """Initialize the writer with configuration.""" + self.config = config or WriterConfig() + + @abstractmethod + def write(self, elements: List[CodeElement], output_path: str) -> None: + """ + Write documentation for the given elements. + + Args: + elements: List of CodeElement objects + output_path: Path to write the documentation + """ + pass + + @abstractmethod + def write_element(self, element: CodeElement) -> str: + """ + Write documentation for a single element. + + Args: + element: CodeElement to document + + Returns: + Formatted documentation string + """ + pass + + +class MarkdownWriter(BaseWriter): + """Writer for Markdown documentation.""" + + def write(self, elements: List[CodeElement], output_path: str) -> None: + """Write Markdown documentation.""" + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + + content = self._generate_content(elements) + output.write_text(content, encoding="utf-8") + + def _generate_content(self, elements: List[CodeElement]) -> str: + """Generate complete Markdown content.""" + lines = [] + + # Group by module + modules = {} + for element in elements: + if element.element_type == ElementType.MODULE: + modules[element.name] = element + + # Generate table of contents + lines.append("# Table of Contents\n") + for module_name in sorted(modules.keys()): + lines.append(f"- [{module_name}](#{module_name.lower().replace(' ', '-')})") + lines.append("") + + # Generate documentation for each module + for module_name in sorted(modules.keys()): + module = modules[module_name] + lines.append(self.write_element(module)) + lines.append("") + + # Add children + for child in module.children: + if self._should_include(child): + lines.append(self.write_element(child)) + lines.append("") + + # Add grandchildren (methods, etc.) + for grandchild in child.children: + if self._should_include(grandchild): + lines.append(self.write_element(grandchild)) + lines.append("") + + return "\n".join(lines) + + def write_element(self, element: CodeElement) -> str: + """Write documentation for a single element.""" + lines = [] + + # Header based on element type + level = self._get_header_level(element.element_type) + header = f"{'#' * level} {element.name}" + lines.append(header) + lines.append("") + + # Signature for functions/methods + if element.signature and element.element_type in [ + ElementType.FUNCTION, + ElementType.METHOD, + ]: + lines.append("```python") + lines.append(element.signature) + lines.append("```") + lines.append("") + + # Docstring + if element.docstring: + lines.append(element.docstring) + lines.append("") + + # Type hints + if self.config.include_type_hints and element.type_hints: + lines.append("**Type Hints:**") + for name, type_hint in element.type_hints.items(): + lines.append(f"- `{name}`: `{type_hint}`") + lines.append("") + + # Parameters + if element.parameters: + lines.append("**Parameters:**") + for param in element.parameters: + param_line = f"- `{param['name']}`" + if param.get("type"): + param_line += f" (`{param['type']}`)" + if param.get("default"): + param_line += f" = {param['default']}" + lines.append(param_line) + lines.append("") + + # Returns + if element.returns: + lines.append(f"**Returns:** `{element.returns}`") + lines.append("") + + # Raises + if element.raises: + lines.append("**Raises:**") + for exc in element.raises: + lines.append(f"- `{exc}`") + lines.append("") + + # Examples + if self.config.include_examples and element.examples: + lines.append("**Examples:**") + for example in element.examples: + lines.append("```python") + lines.append(example) + lines.append("```") + lines.append("") + + # Source link + if self.config.include_source_links and element.file_path: + lines.append(f"[Source]({element.file_path}#L{element.line_number})") + lines.append("") + + return "\n".join(lines) + + def _get_header_level(self, element_type: ElementType) -> int: + """Get header level for element type.""" + levels = { + ElementType.MODULE: 1, + ElementType.CLASS: 2, + ElementType.FUNCTION: 3, + ElementType.METHOD: 4, + } + return levels.get(element_type, 3) + + def _should_include(self, element: CodeElement) -> bool: + """Check if element should be included in documentation.""" + if not self.config.include_private and element.name.startswith("_"): + return False + if not self.config.include_internal and element.name.startswith("__"): + return False + return True + + +class RstWriter(BaseWriter): + """Writer for reStructuredText documentation.""" + + def write(self, elements: List[CodeElement], output_path: str) -> None: + """Write RST documentation.""" + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + + content = self._generate_content(elements) + output.write_text(content, encoding="utf-8") + + def _generate_content(self, elements: List[CodeElement]) -> str: + """Generate complete RST content.""" + lines = [] + + # Group by module + modules = {} + for element in elements: + if element.element_type == ElementType.MODULE: + modules[element.name] = element + + # Generate documentation for each module + for module_name in sorted(modules.keys()): + module = modules[module_name] + lines.append(self.write_element(module)) + lines.append("") + + # Add children + for child in module.children: + if self._should_include(child): + lines.append(self.write_element(child)) + lines.append("") + + # Add grandchildren + for grandchild in child.children: + if self._should_include(grandchild): + lines.append(self.write_element(grandchild)) + lines.append("") + + return "\n".join(lines) + + def write_element(self, element: CodeElement) -> str: + """Write documentation for a single element in RST format.""" + lines = [] + + # Header based on element type + level = self._get_header_level(element.element_type) + char = self._get_header_char(level) + lines.append(element.name) + lines.append(char * len(element.name)) + lines.append("") + + # Signature + if element.signature and element.element_type in [ + ElementType.FUNCTION, + ElementType.METHOD, + ]: + lines.append(".. code-block:: python") + lines.append("") + lines.append(f" {element.signature}") + lines.append("") + + # Docstring + if element.docstring: + lines.append(element.docstring) + lines.append("") + + # Type hints + if self.config.include_type_hints and element.type_hints: + lines.append("**Type Hints:**") + for name, type_hint in element.type_hints.items(): + lines.append(f"- :py:data:`{name}`: :py:class:`{type_hint}`") + lines.append("") + + # Parameters + if element.parameters: + lines.append("**Parameters:**") + for param in element.parameters: + param_line = f"- :py:data:`{param['name']}`" + if param.get("type"): + param_line += f" (:py:class:`{param['type']}`)" + if param.get("default"): + param_line += f" = {param['default']}" + lines.append(param_line) + lines.append("") + + # Returns + if element.returns: + lines.append(f"**Returns:** :py:class:`{element.returns}`") + lines.append("") + + # Examples + if self.config.include_examples and element.examples: + lines.append("**Examples:**") + for example in element.examples: + lines.append(".. code-block:: python") + lines.append("") + for line in example.split("\n"): + lines.append(f" {line}") + lines.append("") + + return "\n".join(lines) + + def _get_header_level(self, element_type: ElementType) -> int: + """Get header level for element type.""" + levels = { + ElementType.MODULE: 1, + ElementType.CLASS: 2, + ElementType.FUNCTION: 3, + ElementType.METHOD: 4, + } + return levels.get(element_type, 3) + + def _get_header_char(self, level: int) -> str: + """Get RST header character for level.""" + chars = ["=", "-", "~", "`"] + return chars[min(level - 1, len(chars) - 1)] + + def _should_include(self, element: CodeElement) -> bool: + """Check if element should be included in documentation.""" + if not self.config.include_private and element.name.startswith("_"): + return False + if not self.config.include_internal and element.name.startswith("__"): + return False + return True + + +class HtmlWriter(BaseWriter): + """Writer for HTML documentation.""" + + def write(self, elements: List[CodeElement], output_path: str) -> None: + """Write HTML documentation.""" + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + + content = self._generate_content(elements) + output.write_text(content, encoding="utf-8") + + def _generate_content(self, elements: List[CodeElement]) -> str: + """Generate complete HTML content.""" + lines = [ + "", + "", + "", + "", + "API Documentation", + "", + "", + "", + ] + + # Group by module + modules = {} + for element in elements: + if element.element_type == ElementType.MODULE: + modules[element.name] = element + + # Generate documentation for each module + for module_name in sorted(modules.keys()): + module = modules[module_name] + lines.append(self.write_element(module)) + + # Add children + for child in module.children: + if self._should_include(child): + lines.append(self.write_element(child)) + + # Add grandchildren + for grandchild in child.children: + if self._should_include(grandchild): + lines.append(self.write_element(grandchild)) + + lines.extend(["", ""]) + return "\n".join(lines) + + def write_element(self, element: CodeElement) -> str: + """Write documentation for a single element in HTML format.""" + lines = [] + + # Header based on element type + level = self._get_header_level(element.element_type) + lines.append(f"{element.name}") + + # Signature + if element.signature and element.element_type in [ + ElementType.FUNCTION, + ElementType.METHOD, + ]: + lines.append("
")
+            lines.append(self._escape_html(element.signature))
+            lines.append("
") + + # Docstring + if element.docstring: + lines.append(f"

{self._escape_html(element.docstring)}

") + + # Type hints + if self.config.include_type_hints and element.type_hints: + lines.append("

Type Hints:

") + lines.append("") + + # Parameters + if element.parameters: + lines.append("

Parameters:

") + lines.append("") + + # Returns + if element.returns: + lines.append(f"

Returns:

{element.returns}

") + + # Examples + if self.config.include_examples and element.examples: + lines.append("

Examples:

") + for example in element.examples: + lines.append("
") + lines.append("
")
+                lines.append(self._escape_html(example))
+                lines.append("
") + lines.append("
") + + return "\n".join(lines) + + def _get_header_level(self, element_type: ElementType) -> int: + """Get header level for element type.""" + levels = { + ElementType.MODULE: 1, + ElementType.CLASS: 2, + ElementType.FUNCTION: 3, + ElementType.METHOD: 4, + } + return levels.get(element_type, 3) + + def _escape_html(self, text: str) -> str: + """Escape HTML special characters.""" + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + ) + + def _should_include(self, element: CodeElement) -> bool: + """Check if element should be included in documentation.""" + if not self.config.include_private and element.name.startswith("_"): + return False + if not self.config.include_internal and element.name.startswith("__"): + return False + return True diff --git a/benchmark_parallel_features.py b/benchmark_parallel_features.py new file mode 100644 index 0000000..21780ca --- /dev/null +++ b/benchmark_parallel_features.py @@ -0,0 +1,182 @@ +"""Benchmark parallel vs sequential feature computation.""" + +import time +import pandas as pd +import numpy as np +from pathlib import Path +import sys +from typing import Dict, List + +# Add project root to path +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from astroml.features import FeatureStore + + +def create_sample_data(n_rows: int = 10000, n_entities: int = 1000) -> pd.DataFrame: + """Create sample transaction data for benchmarking.""" + np.random.seed(42) + + data = pd.DataFrame({ + 'entity_id': np.random.randint(1, n_entities + 1, n_rows), + 'timestamp': pd.date_range('2024-01-01', periods=n_rows, freq='min'), + 'amount': np.random.uniform(1, 1000, n_rows), + 'asset': np.random.choice(['BTC', 'ETH', 'USDT', 'SOL'], n_rows), + }) + + return data + + +def benchmark_feature_computation( + store: FeatureStore, + feature_name: str, + data: pd.DataFrame, + entity_col: str = 'entity_id', + timestamp_col: str = 'timestamp', + n_runs: int = 3, +) -> Dict[str, float]: + """Benchmark feature computation. + + Args: + store: FeatureStore instance + feature_name: Name of feature to compute + data: Input data + entity_col: Entity identifier column + timestamp_col: Timestamp column + n_runs: Number of benchmark runs + + Returns: + Dictionary with timing statistics + """ + times = [] + + for run in range(n_runs): + start_time = time.time() + try: + result = store.compute_feature( + feature_name=feature_name, + data=data, + entity_col=entity_col, + timestamp_col=timestamp_col, + ) + elapsed = time.time() - start_time + times.append(elapsed) + print(f" Run {run + 1}: {elapsed:.3f}s ({len(result)} rows)") + except Exception as e: + print(f" Run {run + 1}: FAILED - {e}") + continue + + if not times: + return {"mean": 0.0, "std": 0.0, "min": 0.0, "max": 0.0} + + return { + "mean": np.mean(times), + "std": np.std(times), + "min": np.min(times), + "max": np.max(times), + } + + +def run_benchmarks(): + """Run comprehensive benchmarks comparing parallel vs sequential execution.""" + print("=" * 70) + print("Parallel Feature Computation Benchmark") + print("=" * 70) + + # Test different data sizes + data_sizes = [1000, 5000, 10000, 50000] + n_entities_list = [100, 500, 1000, 5000] + + # Test different worker configurations + worker_configs = [ + {"max_workers": 1, "enable_parallel": False, "name": "Sequential"}, + {"max_workers": 2, "enable_parallel": True, "name": "Parallel (2 workers)"}, + {"max_workers": 4, "enable_parallel": True, "name": "Parallel (4 workers)"}, + {"max_workers": 8, "enable_parallel": True, "name": "Parallel (8 workers)"}, + ] + + results = [] + + for data_size, n_entities in zip(data_sizes, n_entities_list): + print(f"\n{'=' * 70}") + print(f"Data Size: {data_size} rows, {n_entities} entities") + print(f"{'=' * 70}") + + data = create_sample_data(n_rows=data_size, n_entities=n_entities) + + config_results = {} + + for config in worker_configs: + print(f"\n{config['name']}:") + + # Create feature store with specific configuration + store = FeatureStore( + storage_path=f"./benchmark_store_{config['name'].replace(' ', '_').replace('(', '').replace(')', '')}", + max_workers=config["max_workers"], + enable_parallel=config["enable_parallel"], + chunk_size=100, + ) + + # Benchmark feature computation + try: + stats = benchmark_feature_computation( + store=store, + feature_name="daily_transaction_count", + data=data, + n_runs=3, + ) + config_results[config["name"]] = stats + print(f" Mean: {stats['mean']:.3f}s ± {stats['std']:.3f}s") + except Exception as e: + print(f" FAILED: {e}") + config_results[config["name"]] = {"mean": 0.0, "std": 0.0} + + results.append({ + "data_size": data_size, + "n_entities": n_entities, + "results": config_results, + }) + + # Calculate and display speedup + print(f"\n{'=' * 70}") + print("Speedup Analysis") + print(f"{'=' * 70}") + + for result in results: + data_size = result["data_size"] + n_entities = result["n_entities"] + config_results = result["results"] + + sequential_time = config_results.get("Sequential", {}).get("mean", 0.0) + + print(f"\nData Size: {data_size} rows, {n_entities} entities") + print(f"Sequential baseline: {sequential_time:.3f}s") + + for config_name in ["Parallel (2 workers)", "Parallel (4 workers)", "Parallel (8 workers)"]: + if config_name in config_results: + parallel_time = config_results[config_name]["mean"] + if sequential_time > 0 and parallel_time > 0: + speedup = sequential_time / parallel_time + efficiency = speedup / int(config_name.split("(")[1].split()[0]) * 100 + print(f" {config_name}: {parallel_time:.3f}s ({speedup:.2f}x speedup, {efficiency:.1f}% efficiency)") + + # Summary statistics + print(f"\n{'=' * 70}") + print("Summary") + print(f"{'=' * 70}") + + for result in results: + data_size = result["data_size"] + config_results = result["results"] + + sequential_time = config_results.get("Sequential", {}).get("mean", 0.0) + parallel_4_time = config_results.get("Parallel (4 workers)", {}).get("mean", 0.0) + + if sequential_time > 0 and parallel_4_time > 0: + speedup = sequential_time / parallel_4_time + print(f"Data size {data_size}: {speedup:.2f}x speedup with 4 workers") + + +if __name__ == "__main__": + run_benchmarks() diff --git a/profile_feature_computation.py b/profile_feature_computation.py new file mode 100644 index 0000000..9eb00de --- /dev/null +++ b/profile_feature_computation.py @@ -0,0 +1,72 @@ +"""Profile feature computation to identify bottlenecks.""" + +import cProfile +import pstats +import io +import pandas as pd +import numpy as np +from pathlib import Path +import sys + +# Add project root to path +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from astroml.features import FeatureStore + + +def create_sample_data(n_rows: int = 10000) -> pd.DataFrame: + """Create sample transaction data for profiling.""" + np.random.seed(42) + + data = pd.DataFrame({ + 'entity_id': np.random.randint(1, 1000, n_rows), + 'timestamp': pd.date_range('2024-01-01', periods=n_rows, freq='min'), + 'amount': np.random.uniform(1, 1000, n_rows), + 'asset': np.random.choice(['BTC', 'ETH', 'USDT', 'SOL'], n_rows), + }) + + return data + + +def profile_sequential_computation(): + """Profile sequential feature computation.""" + print("Profiling sequential feature computation...") + + # Create feature store + store = FeatureStore(storage_path="./profile_feature_store") + + # Create sample data + data = create_sample_data(n_rows=10000) + + # Profile computation + profiler = cProfile.Profile() + profiler.enable() + + try: + # Compute multiple features sequentially + features = ['daily_transaction_count', 'transaction_burstiness'] + for feature_name in features: + result = store.compute_feature( + feature_name=feature_name, + data=data, + entity_col='entity_id', + timestamp_col='timestamp', + ) + print(f"Computed {feature_name}: {len(result)} rows") + except Exception as e: + print(f"Error during computation: {e}") + finally: + profiler.disable() + + # Print profiling results + s = io.StringIO() + ps = pstats.Stats(profiler, stream=s).sort_stats('cumulative') + ps.print_stats(20) + print(s.getvalue()) + + return profiler + + +if __name__ == "__main__": + profile_sequential_computation() diff --git a/tests/features/test_feature_store.py b/tests/features/test_feature_store.py index ab77180..1e09ead 100644 --- a/tests/features/test_feature_store.py +++ b/tests/features/test_feature_store.py @@ -832,15 +832,322 @@ def test_hit_rate_and_miss_rate_sum_to_one(self, feature_store, sample_values): feature_name = "feat_rates" feature_store.store_feature(feature_name, sample_values) - feature_store._cache_hits = 0 - feature_store._cache_misses = 0 - feature_store.get_feature(feature_name, use_cache=True) # miss - feature_store.get_feature(feature_name, use_cache=True) # hit +class TestParallelFeatureComputation: + """Tests for parallel feature computation functionality. - stats = feature_store.get_cache_stats() - assert abs(stats["hit_rate"] + stats["miss_rate"] - 1.0) < 1e-9 - assert abs(stats["hit_rate"] - 0.5) < 1e-9 + Covers parallel execution, fallback to sequential, chunking, + thread safety, and configuration options. + """ + + @pytest.fixture + def temp_storage_path(self): + temp_dir = tempfile.mkdtemp() + yield temp_dir + shutil.rmtree(temp_dir) + + @pytest.fixture + def feature_store(self, temp_storage_path): + """Create feature store instance.""" + return FeatureStore(temp_storage_path) + + @pytest.fixture + def large_sample_data(self): + """Create large sample data for parallel computation testing.""" + np.random.seed(42) + n_rows = 500 + n_entities = 50 + + return pd.DataFrame({ + "entity_id": np.random.randint(1, n_entities + 1, n_rows), + "timestamp": pd.date_range("2023-01-01", periods=n_rows, freq="min"), + "amount": np.random.uniform(1, 1000, n_rows), + "src": np.random.randint(1, n_entities + 1, n_rows), + "dst": np.random.randint(1, n_entities + 1, n_rows), + }) + + @pytest.fixture + def small_sample_data(self): + """Create small sample data (below chunk size).""" + return pd.DataFrame({ + "entity_id": ["acc1", "acc2", "acc3"], + "timestamp": [ + datetime(2023, 1, 1), + datetime(2023, 1, 2), + datetime(2023, 1, 3), + ], + "amount": [100.0, 200.0, 150.0], + }) + + def test_parallel_configuration_defaults(self, temp_storage_path): + """Test default parallel computation configuration.""" + store = FeatureStore(temp_storage_path) + + assert store._max_workers == 4 + assert store._chunk_size == 100 + assert store._enable_parallel == True + + def test_parallel_configuration_custom(self, temp_storage_path): + """Test custom parallel computation configuration.""" + store = FeatureStore( + temp_storage_path, + max_workers=8, + chunk_size=50, + enable_parallel=True, + ) + + assert store._max_workers == 8 + assert store._chunk_size == 50 + assert store._enable_parallel == True + + def test_parallel_disabled(self, temp_storage_path): + """Test disabling parallel computation.""" + store = FeatureStore( + temp_storage_path, + max_workers=1, + enable_parallel=False, + ) + + assert store._enable_parallel == False + + def test_parallel_compute_feature_large_data(self, feature_store, large_sample_data): + """Test parallel computation with large data.""" + def test_computer(data, entity_col, timestamp_col, **kwargs): + """Simple test computer.""" + result = data.groupby(entity_col).agg({ + "amount": ["sum", "mean", "count"] + }) + result.columns = ["sum", "mean", "count"] + return result + + feature_store.register_feature( + "test_parallel_feature", + test_computer, + "Test parallel feature", + ) + + # Test with parallel enabled + store_parallel = FeatureStore( + feature_store.storage.storage_path, + max_workers=4, + chunk_size=20, + enable_parallel=True, + ) + + result = store_parallel.compute_feature( + feature_name="test_parallel_feature", + data=large_sample_data, + entity_col="entity_id", + timestamp_col="timestamp", + ) + + assert isinstance(result, pd.DataFrame) + assert len(result) > 0 + + def test_sequential_compute_feature_small_data(self, feature_store, small_sample_data): + """Test sequential computation with small data (below chunk size).""" + def test_computer(data, entity_col, timestamp_col, **kwargs): + """Simple test computer.""" + result = data.groupby(entity_col).agg({"amount": "sum"}) + result.columns = ["sum"] + return result + + feature_store.register_feature( + "test_sequential_feature", + test_computer, + "Test sequential feature", + ) + + store_parallel = FeatureStore( + feature_store.storage.storage_path, + max_workers=4, + chunk_size=100, + enable_parallel=True, + ) + + result = store_parallel.compute_feature( + feature_name="test_sequential_feature", + data=small_sample_data, + entity_col="entity_id", + timestamp_col="timestamp", + ) + + assert isinstance(result, pd.DataFrame) + assert len(result) > 0 + + def test_parallel_fallback_to_sequential(self, feature_store, large_sample_data): + """Test fallback to sequential when parallel computation fails.""" + def failing_computer(data, entity_col, timestamp_col, **kwargs): + """Computer that fails in parallel but works sequentially.""" + # Simulate a failure that might occur in parallel + if len(data) > 100: + raise RuntimeError("Simulated parallel failure") + return data.groupby(entity_col).agg({"amount": "sum"}) + + feature_store.register_feature( + "test_fallback_feature", + failing_computer, + "Test fallback feature", + ) + + store_parallel = FeatureStore( + feature_store.storage.storage_path, + max_workers=4, + chunk_size=20, + enable_parallel=True, + ) + + # This should fallback to sequential + with pytest.raises(RuntimeError): + store_parallel.compute_feature( + feature_name="test_fallback_feature", + data=large_sample_data, + entity_col="entity_id", + timestamp_col="timestamp", + ) + + def test_parallel_get_features_for_entities(self, feature_store): + """Test parallel fetching of multiple features.""" + # Store multiple test features + for i in range(3): + test_values = pd.DataFrame({ + f"feature{i}": [i+1, i+2, i+3], + }, index=["entity1", "entity2", "entity3"]) + feature_store.store_feature(f"feature{i}", test_values) + + store_parallel = FeatureStore( + feature_store.storage.storage_path, + max_workers=4, + enable_parallel=True, + ) + + result = store_parallel.get_features_for_entities( + feature_names=["feature0", "feature1", "feature2"], + entity_ids=["entity1", "entity2"], + parallel=True, + ) + + assert isinstance(result, pd.DataFrame) + assert len(result) == 2 + assert "feature0" in result.columns + assert "feature1" in result.columns + assert "feature2" in result.columns + + def test_sequential_get_features_for_entities(self, feature_store): + """Test sequential fetching of multiple features.""" + # Store multiple test features + for i in range(3): + test_values = pd.DataFrame({ + f"feature{i}": [i+1, i+2, i+3], + }, index=["entity1", "entity2", "entity3"]) + feature_store.store_feature(f"feature{i}", test_values) + + store_parallel = FeatureStore( + feature_store.storage.storage_path, + max_workers=4, + enable_parallel=True, + ) + + result = store_parallel.get_features_for_entities( + feature_names=["feature0", "feature1", "feature2"], + entity_ids=["entity1", "entity2"], + parallel=False, + ) + + assert isinstance(result, pd.DataFrame) + assert len(result) == 2 + + def test_thread_safety_with_cache(self, feature_store): + """Test thread safety with cache operations during parallel computation.""" + def test_computer(data, entity_col, timestamp_col, **kwargs): + """Test computer that simulates cache operations.""" + result = data.groupby(entity_col).agg({"amount": "sum"}) + result.columns = ["sum"] + return result + + feature_store.register_feature( + "test_thread_safety", + test_computer, + "Test thread safety feature", + ) + + # Create large data to trigger parallel computation + np.random.seed(42) + large_data = pd.DataFrame({ + "entity_id": np.random.randint(1, 100, 500), + "timestamp": pd.date_range("2023-01-01", periods=500, freq="min"), + "amount": np.random.uniform(1, 1000, 500), + }) + + store_parallel = FeatureStore( + feature_store.storage.storage_path, + max_workers=4, + chunk_size=50, + enable_parallel=True, + ) + + # Compute feature (should use parallel computation) + result = store_parallel.compute_feature( + feature_name="test_thread_safety", + data=large_data, + entity_col="entity_id", + timestamp_col="timestamp", + ) + + assert isinstance(result, pd.DataFrame) + + # Verify cache is still consistent + cache_stats = store_parallel.get_cache_stats() + assert cache_stats is not None + + def test_chunk_size_configuration(self, temp_storage_path): + """Test that chunk size affects parallel computation behavior.""" + store_small_chunk = FeatureStore( + temp_storage_path, + max_workers=4, + chunk_size=10, + enable_parallel=True, + ) + + store_large_chunk = FeatureStore( + temp_storage_path, + max_workers=4, + chunk_size=1000, + enable_parallel=True, + ) + + assert store_small_chunk._chunk_size == 10 + assert store_large_chunk._chunk_size == 1000 + + def test_max_workers_configuration(self, temp_storage_path): + """Test that max_workers configuration is respected.""" + store_2_workers = FeatureStore( + temp_storage_path, + max_workers=2, + enable_parallel=True, + ) + + store_8_workers = FeatureStore( + temp_storage_path, + max_workers=8, + enable_parallel=True, + ) + + assert store_2_workers._max_workers == 2 + assert store_8_workers._max_workers == 8 + + def test_create_feature_store_with_parallel_config(self, temp_storage_path): + """Test create_feature_store with parallel configuration.""" + store = create_feature_store( + temp_storage_path, + max_workers=8, + chunk_size=50, + enable_parallel=True, + ) + + assert store._max_workers == 8 + assert store._chunk_size == 50 + assert store._enable_parallel == True def test_stats_zero_before_any_lookup(self, feature_store): """All rate/counter fields are 0 on a fresh store.""" diff --git a/tools/doc_generator/__init__.py b/tools/doc_generator/__init__.py new file mode 100644 index 0000000..c6a377b --- /dev/null +++ b/tools/doc_generator/__init__.py @@ -0,0 +1,3 @@ +""" +Documentation generator CLI tool. +""" diff --git a/tools/doc_generator/cli.py b/tools/doc_generator/cli.py new file mode 100644 index 0000000..b405e60 --- /dev/null +++ b/tools/doc_generator/cli.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python +""" +CLI tool for documentation generation. + +This script provides a command-line interface for generating, +updating, and validating documentation. +""" + +import argparse +import sys +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +from astroml.llm.docs.generator import ( + DocumentationGenerator, + GenerationConfig, + DocType, + OutputFormat, +) +from astroml.llm.docs.validator import DocumentationValidator +from astroml.llm.docs.updater import DocumentationUpdater + + +def main(): + """Main CLI entry point.""" + parser = argparse.ArgumentParser( + description="Generate and manage documentation for astroml" + ) + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Generate command + generate_parser = subparsers.add_parser("generate", help="Generate documentation") + generate_parser.add_argument( + "source", help="Source file or directory to document" + ) + generate_parser.add_argument( + "-o", "--output", help="Output directory or file" + ) + generate_parser.add_argument( + "-f", "--format", + choices=["markdown", "rst", "html"], + default="markdown", + help="Output format (default: markdown)" + ) + generate_parser.add_argument( + "-t", "--type", + choices=["api", "code", "architecture", "tutorial", "changelog", "readme"], + default="code", + help="Type of documentation (default: code)" + ) + generate_parser.add_argument( + "--include-private", action="store_true", + help="Include private members" + ) + generate_parser.add_argument( + "--include-internal", action="store_true", + help="Include internal members" + ) + generate_parser.add_argument( + "--no-examples", action="store_true", + help="Exclude code examples" + ) + generate_parser.add_argument( + "--no-type-hints", action="store_true", + help="Exclude type hints" + ) + generate_parser.add_argument( + "--no-validate", action="store_true", + help="Skip validation" + ) + + # Update command + update_parser = subparsers.add_parser("update", help="Update existing documentation") + update_parser.add_argument("doc_path", help="Path to documentation file") + update_parser.add_argument( + "source_paths", nargs="+", + help="Source file paths" + ) + update_parser.add_argument( + "--no-preserve-edits", action="store_true", + help="Do not preserve manual edits" + ) + + # Validate command + validate_parser = subparsers.add_parser("validate", help="Validate documentation") + validate_parser.add_argument("doc_path", help="Path to documentation file") + validate_parser.add_argument( + "--code-dir", help="Directory containing source code for consistency checks" + ) + + # Check outdated command + check_parser = subparsers.add_parser("check-outdated", help="Check for outdated documentation") + check_parser.add_argument( + "doc_dir", help="Directory containing documentation" + ) + + args = parser.parse_args() + + if not args.command: + parser.print_help() + sys.exit(1) + + if args.command == "generate": + handle_generate(args) + elif args.command == "update": + handle_update(args) + elif args.command == "validate": + handle_validate(args) + elif args.command == "check-outdated": + handle_check_outdated(args) + + +def handle_generate(args): + """Handle the generate command.""" + config = GenerationConfig( + doc_type=DocType(args.type), + output_format=OutputFormat(args.format), + output_dir=args.output or "docs", + include_private=args.include_private, + include_internal=args.include_internal, + include_examples=not args.no_examples, + include_type_hints=not args.no_type_hints, + validate_after_generation=not args.no_validate, + ) + + generator = DocumentationGenerator(config) + + source_path = Path(args.source) + if source_path.is_file(): + result = generator.generate_from_file(str(source_path), args.output) + elif source_path.is_dir(): + result = generator.generate_from_directory(str(source_path), args.output) + else: + print(f"Error: Source path does not exist: {args.source}") + sys.exit(1) + + if result.success: + print(f"āœ“ Documentation generated successfully") + print(f" Files generated: {len(result.files_generated)}") + for file in result.files_generated: + print(f" - {file}") + print(f" Duration: {result.duration_seconds:.2f}s") + + if result.validation_result: + print(f"\nValidation Results:") + print(f" Valid: {result.validation_result.is_valid}") + print(f" Completeness Score: {result.validation_result.completeness_score:.1f}/100") + print(f" Readability Score: {result.validation_result.readability_score:.1f}/100") + print(f" Issues: {len(result.validation_result.issues)}") + + if result.validation_result.issues: + print("\n Issues found:") + for issue in result.validation_result.issues: + print(f" - [{issue.severity.value.upper()}] {issue.message}") + if issue.suggestion: + print(f" Suggestion: {issue.suggestion}") + else: + print(f"āœ— Documentation generation failed") + print(f" Error: {result.error}") + sys.exit(1) + + +def handle_update(args): + """Handle the update command.""" + updater = DocumentationUpdater() + + result = updater.update_documentation( + args.doc_path, + args.source_paths, + preserve_manual_edits=not args.no_preserve_edits, + ) + + if result.success: + print(f"āœ“ Documentation updated successfully") + print(f" Files updated: {len(result.updated_files)}") + for file in result.updated_files: + print(f" - {file}") + print(f" Files skipped: {len(result.skipped_files)}") + print(f" Changes: {result.changes_made}") + else: + print(f"āœ— Documentation update failed") + for error in result.errors: + print(f" Error: {error}") + sys.exit(1) + + +def handle_validate(args): + """Handle the validate command.""" + validator = DocumentationValidator() + + code_elements = None + if args.code_dir: + from astroml.llm.docs.code_analyzer import CodeAnalyzer + analyzer = CodeAnalyzer() + code_elements = analyzer.analyze_directory(args.code_dir) + + result = validator.validate_documentation(args.doc_path, code_elements) + + print(result.get_summary()) + + if result.issues: + print("\nDetailed Issues:") + for issue in result.issues: + print(f" [{issue.severity.value.upper()}] {issue.message}") + print(f" Location: {issue.location}") + if issue.suggestion: + print(f" Suggestion: {issue.suggestion}") + + sys.exit(0 if result.is_valid else 1) + + +def handle_check_outdated(args): + """Handle the check-outdated command.""" + updater = DocumentationUpdater() + + outdated = updater.detect_outdated_docs(args.doc_dir) + + if outdated: + print(f"Found {len(outdated)} outdated documentation file(s):") + for doc in outdated: + print(f" - {doc}") + sys.exit(1) + else: + print("āœ“ All documentation is up to date") + sys.exit(0) + + +if __name__ == "__main__": + main()