diff --git a/WORKLOG.md b/WORKLOG.md index b4cde30..b505902 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -7,3 +7,4 @@ | Date | Task ID | Layer | Action | Files | Notes | device | |---|---|---|---|---|---|---| | 2026-06-20 | T3B-2026-06-20 | governance | init | (all) | Initial SSOT bundle generation (T3B) | macbook | +| 2026-07-23 | AUDIT-LANE-FLAT-LAYOUT-001 | packaging | fix | pyproject.toml, tests/test_install_path.py, tests/__init__.py | Add `[tool.setuptools] py-modules = []` + `[tool.setuptools.packages.find]` no-op to suppress flat-layout auto-discovery; new regression test runs `uv pip install` against the workspace path and asserts success + no flat-layout error. DAG tick: +1. Second lane after PR #42 (fix/auth-kit-gitlink). | macbook | diff --git a/packages/phenokit-config-kit/ORIGIN.md b/packages/phenokit-config-kit/ORIGIN.md new file mode 100644 index 0000000..3087918 --- /dev/null +++ b/packages/phenokit-config-kit/ORIGIN.md @@ -0,0 +1,5 @@ +# Origin - phenokit-config-kit + +Folded from `KooshaPari/PhenoKits/libs/python/phenokit-config-kit` into `KooshaPari/phenotype-python-sdk/packages/phenokit-config-kit` on 2026-06-20. + +This package is intentionally kept distinct from `packages/phenotype-config` because the source contains config-kit linting/pre-commit/pytest scaffolds that are not file-equivalent to the existing SDK config package. diff --git a/packages/phenokit-config-kit/README.md b/packages/phenokit-config-kit/README.md new file mode 100644 index 0000000..b55e09f --- /dev/null +++ b/packages/phenokit-config-kit/README.md @@ -0,0 +1,226 @@ +# PhenoKit Config Kit + +A comprehensive configuration management package for Python projects providing pytest plugins, fixtures, linting configurations, and pre-commit hooks. + +## Features + +- **Pre-commit Configuration**: Multiple pre-commit hook configurations (basic, comprehensive, security) +- **Pytest Configuration**: Standardized testing configurations for different scenarios +- **Pytest Plugins**: Architecture, performance, and security testing plugins +- **Pytest Fixtures**: Common, performance, and security fixtures for testing +- **Linting Configuration**: Code quality and style enforcement configs +- **Test Data Factory**: Generate consistent test data across projects + +## Installation + +```bash +# Basic installation +pip install phenokit-config-kit + +# With all dependencies +pip install phenokit-config-kit[all] + +# With specific extras +pip install phenokit-config-kit[testing,linting] +``` + +## Quick Start + +### Using Pre-commit Configurations + +Copy the desired pre-commit configuration to your project: + +```bash +# Basic configuration +cp pre-commit/basic.yaml .pre-commit-config.yaml + +# Comprehensive configuration +cp pre-commit/comprehensive.yaml .pre-commit-config.yaml + +# Security-focused configuration +cp pre-commit/security.yaml .pre-commit-config.yaml +``` + +Install pre-commit hooks: +```bash +pre-commit install +``` + +### Using Pytest Configurations + +```bash +# Use basic configuration +pytest --ini=config-kit/pytest/basic.ini + +# Use comprehensive configuration +pytest --ini=config-kit/pytest/comprehensive.ini + +# Use CI configuration +pytest --ini=config-kit/pytest/ci.ini +``` + +### Using Pytest Plugins + +The pytest plugins are automatically registered when you install the package: + +```python +# Architecture testing (auto-added to test collection) +pytest --max-file-size=500 --max-complexity=10 + +# Performance testing +pytest -m performance --benchmark-threshold=1.0 + +# Security testing +pytest -m security +``` + +### Using Fixtures + +```python +import pytest +from config_kit.pytest.fixtures.common import mock_client, test_data +from config_kit.pytest.fixtures.performance import performance_monitor +from config_kit.pytest.fixtures.security import security_context + +def test_with_fixtures(mock_client, test_data): + result = mock_client.call_tool("test_tool", test_data["user"]) + assert result["success"] is True + +@pytest.mark.performance +def test_performance(performance_monitor): + with performance_monitor["measure"]("test_operation"): + result = expensive_operation() + assert result is not None +``` + +### Using Test Data Factory + +```python +from config_kit.pytest.data.factory import TestDataFactory + +factory = TestDataFactory(seed=42) + +# Generate user data +user_data = factory.user_data() + +# Generate organization data +org_data = factory.organization_data() + +# Generate project data +project_data = factory.project_data(organization_id="org-123") +``` + +## Configuration Files + +### Pre-commit Configurations +- `pre-commit/basic.yaml` - Basic pre-commit hooks +- `pre-commit/comprehensive.yaml` - Full linting and security suite +- `pre-commit/security.yaml` - Security-focused configuration + +### Pytest Configurations +- `pytest/basic.ini` - Basic pytest configuration +- `pytest/comprehensive.ini` - Enterprise-grade configuration +- `pytest/ci.ini` - CI/CD optimized +- `pytest/parallel.ini` - Parallel execution optimized +- `pytest/performance.ini` - Performance testing +- `pytest/security.ini` - Security testing + +### Linting Configurations +- `linting/pyproject.toml` - Python linter configs (Ruff, Black, isort, mypy) +- `linting/.editorconfig` - Editor configuration +- `linting/.markdownlint.json` - Markdown linting rules +- `linting/linters.toml` - Centralized linter configuration +- `linting/ci-cd/gatekeeper.toml` - Quality gate definitions + +## Pytest Plugins + +### Architecture Plugin + +Provides architecture fitness tests: +- File size validation +- Import boundary enforcement +- Dependency direction validation +- Cyclomatic complexity analysis + +### Performance Plugin + +Provides performance testing capabilities: +- Benchmark testing +- Memory profiling +- Performance regression detection + +### Security Plugin + +Provides security testing capabilities: +- Vulnerability scanning +- Forbidden imports check +- Hardcoded secrets detection + +## Git Hooks + +The package includes useful git hooks: + +### Pre-commit Hook +Validates staged files with linters (Ruff, Black, isort, mypy, bandit, etc.) + +### Commit-msg Hook +Validates commit messages for: +- Conventional commits format +- Proper capitalization +- Proper punctuation +- Minimum length requirements + +To use the git hooks: +```bash +cp linting/git-hooks/pre-commit .git/hooks/pre-commit +chmod +x .git/hooks/pre-commit + +cp linting/git-hooks/commit-msg .git/hooks/commit-msg +chmod +x .git/hooks/commit-msg +``` + +## Quality Gate Script + +Run comprehensive quality checks: + +```bash +bash linting/ci-cd/scripts/quality-gate.sh +``` + +This runs all configured linters and generates a `quality_gate_results.json` report. + +## Development + +### Project Structure + +``` +config-kit/ +├── pyproject.toml # Package configuration +├── README.md # This file +├── pre-commit/ # Pre-commit configurations +├── pytest/ # Pytest configurations, plugins, fixtures +│ ├── plugins/ # Pytest plugins +│ ├── fixtures/ # Pytest fixtures +│ └── data/ # Test data utilities +└── linting/ # Linting configurations and scripts + ├── ci-cd/ # CI/CD scripts + └── git-hooks/ # Git hooks +``` + +### Running Tests + +```bash +pytest --ini=pytest/comprehensive.ini +``` + +## License + +MIT License - See LICENSE file for details. + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run the quality gate script +5. Submit a pull request diff --git a/packages/phenokit-config-kit/config_kit/__init__.py b/packages/phenokit-config-kit/config_kit/__init__.py new file mode 100644 index 0000000..c3e4a18 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/__init__.py @@ -0,0 +1,16 @@ +""" +Config Kit - A comprehensive configuration management package for Python projects. + +This package provides: +- Pre-commit hook configurations +- Pytest configurations, plugins, and fixtures +- Linting configurations +- Test data factory +""" + +__version__ = "0.1.0" +__all__ = [ + "pytest", + "linting", + "pre_commit", +] diff --git a/packages/phenokit-config-kit/config_kit/pytest/__init__.py b/packages/phenokit-config-kit/config_kit/pytest/__init__.py new file mode 100644 index 0000000..b90e163 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/__init__.py @@ -0,0 +1,7 @@ +""" +Pytest module for config-kit. + +This module provides pytest plugins, fixtures, and configurations. +""" + +__version__ = "0.1.0" diff --git a/packages/phenokit-config-kit/config_kit/pytest/data/__init__.py b/packages/phenokit-config-kit/config_kit/pytest/data/__init__.py new file mode 100644 index 0000000..4269819 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/data/__init__.py @@ -0,0 +1,13 @@ +""" +Test data management for config-kit package. + +This module provides utilities for managing test data across different projects. +""" + +__version__ = "0.1.0" +__all__ = [ + "TestDataFactory", + "TestDataGenerator", + "TestDataManager", + "TestDataValidator", +] diff --git a/packages/phenokit-config-kit/config_kit/pytest/data/factory.py b/packages/phenokit-config-kit/config_kit/pytest/data/factory.py new file mode 100644 index 0000000..10944f7 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/data/factory.py @@ -0,0 +1,225 @@ +""" +Test data factory for generating test data. + +This module provides a factory for generating test data across different projects. +""" + +import random +import string +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any + + +@dataclass +class TestDataFactory: + """Factory for generating test data.""" + + seed: int | None = None + _random: random.Random = field(default_factory=random.Random) + + def __post_init__(self): + if self.seed is not None: + self._random.seed(self.seed) + + def random_string(self, length: int = 10, chars: str = None) -> str: + """Generate a random string.""" + if chars is None: + chars = string.ascii_letters + string.digits + return "".join(self._random.choices(chars, k=length)) + + def random_email(self, domain: str = "example.com") -> str: + """Generate a random email address.""" + username = self.random_string(8, string.ascii_lowercase + string.digits) + return f"{username}@{domain}" + + def random_uuid(self) -> str: + """Generate a random UUID.""" + return str(uuid.uuid4()) + + def random_int(self, min_val: int = 0, max_val: int = 100) -> int: + """Generate a random integer.""" + return self._random.randint(min_val, max_val) + + def random_float(self, min_val: float = 0.0, max_val: float = 1.0) -> float: + """Generate a random float.""" + return self._random.uniform(min_val, max_val) + + def random_choice(self, choices: list[Any]) -> Any: + """Choose a random item from a list.""" + return self._random.choice(choices) + + def random_choices(self, choices: list[Any], k: int = 1) -> list[Any]: + """Choose multiple random items from a list.""" + return self._random.choices(choices, k=k) + + def random_bool(self) -> bool: + """Generate a random boolean.""" + return self._random.choice([True, False]) + + def random_datetime(self, start: datetime = None, end: datetime = None) -> datetime: + """Generate a random datetime.""" + if start is None: + start = datetime.now() - timedelta(days=365) + if end is None: + end = datetime.now() + + delta = end - start + random_seconds = self._random.randint(0, int(delta.total_seconds())) + return start + timedelta(seconds=random_seconds) + + def random_date(self, start: datetime = None, end: datetime = None) -> datetime: + """Generate a random date (time set to 00:00:00).""" + dt = self.random_datetime(start, end) + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + def user_data(self, **overrides) -> dict[str, Any]: + """Generate user test data.""" + data = { + "id": self.random_uuid(), + "username": self.random_string(8, string.ascii_lowercase + string.digits), + "email": self.random_email(), + "full_name": f"{self.random_string(6)} {self.random_string(8)}", + "roles": self.random_choices(["user", "admin", "moderator"], k=self.random_int(1, 3)), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + "is_active": self.random_bool(), + } + data.update(overrides) + return data + + def organization_data(self, **overrides) -> dict[str, Any]: + """Generate organization test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Organization {self.random_string(6)}", + "description": f"Test organization description {self.random_string(20)}", + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + "is_active": self.random_bool(), + } + data.update(overrides) + return data + + def project_data(self, organization_id: str = None, **overrides) -> dict[str, Any]: + """Generate project test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Project {self.random_string(6)}", + "description": f"Test project description {self.random_string(20)}", + "status": self.random_choice(["active", "inactive", "archived"]), + "organization_id": organization_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def document_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate document test data.""" + data = { + "id": self.random_uuid(), + "title": f"Test Document {self.random_string(6)}", + "content": f"Test document content {self.random_string(50)}", + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def requirement_data(self, document_id: str = None, **overrides) -> dict[str, Any]: + """Generate requirement test data.""" + data = { + "id": self.random_uuid(), + "title": f"Test Requirement {self.random_string(6)}", + "description": f"Test requirement description {self.random_string(30)}", + "priority": self.random_choice(["low", "medium", "high", "critical"]), + "status": self.random_choice(["draft", "review", "approved", "rejected"]), + "document_id": document_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def test_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate test entity data.""" + data = { + "id": self.random_uuid(), + "name": f"Test {self.random_string(6)}", + "description": f"Test description {self.random_string(20)}", + "status": self.random_choice(["pending", "running", "passed", "failed", "skipped"]), + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def relationship_data(self, source_id: str = None, target_id: str = None, **overrides) -> dict[str, Any]: + """Generate relationship test data.""" + data = { + "id": self.random_uuid(), + "source_id": source_id or self.random_uuid(), + "target_id": target_id or self.random_uuid(), + "relationship_type": self.random_choice(["parent", "child", "sibling", "related"]), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def workflow_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate workflow test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Workflow {self.random_string(6)}", + "description": f"Test workflow description {self.random_string(20)}", + "status": self.random_choice(["draft", "active", "paused", "completed"]), + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def query_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate query test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Query {self.random_string(6)}", + "query": f"SELECT * FROM test_table WHERE id = '{self.random_uuid()}'", + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def create_related_data(self, entity_type: str, count: int = 5, **overrides) -> list[dict[str, Any]]: + """Create related test data.""" + data = [] + for _ in range(count): + if entity_type == "user": + data.append(self.user_data(**overrides)) + elif entity_type == "organization": + data.append(self.organization_data(**overrides)) + elif entity_type == "project": + data.append(self.project_data(**overrides)) + elif entity_type == "document": + data.append(self.document_data(**overrides)) + elif entity_type == "requirement": + data.append(self.requirement_data(**overrides)) + elif entity_type == "test": + data.append(self.test_data(**overrides)) + elif entity_type == "relationship": + data.append(self.relationship_data(**overrides)) + elif entity_type == "workflow": + data.append(self.workflow_data(**overrides)) + elif entity_type == "query": + data.append(self.query_data(**overrides)) + else: + raise ValueError(f"Unknown entity type: {entity_type}") + return data diff --git a/packages/phenokit-config-kit/config_kit/pytest/fixtures/__init__.py b/packages/phenokit-config-kit/config_kit/pytest/fixtures/__init__.py new file mode 100644 index 0000000..fe01c35 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/fixtures/__init__.py @@ -0,0 +1,18 @@ +""" +Pytest fixtures for config-kit package. + +This module provides a collection of pytest fixtures that can be used +across different Python projects. +""" + +__version__ = "0.1.0" +__all__ = [ + "cache_fixture", + "database_fixture", + "message_broker_fixture", + "mock_client", + "performance_monitor", + "security_context", + "temp_dir", + "test_data", +] diff --git a/packages/phenokit-config-kit/config_kit/pytest/fixtures/common.py b/packages/phenokit-config-kit/config_kit/pytest/fixtures/common.py new file mode 100644 index 0000000..598cde9 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/fixtures/common.py @@ -0,0 +1,226 @@ +""" +Common pytest fixtures for testing. + +This module provides commonly used fixtures across different projects. +""" + +import os +import shutil +import tempfile +from collections.abc import Generator +from pathlib import Path +from typing import Any +from unittest.mock import Mock + +import pytest + + +@pytest.fixture +def temp_dir() -> Generator[Path, None, None]: + """Create a temporary directory for testing.""" + temp_path = Path(tempfile.mkdtemp()) + try: + yield temp_path + finally: + shutil.rmtree(temp_path, ignore_errors=True) + + +@pytest.fixture +def mock_client() -> Mock: + """Create a mock client for testing.""" + client = Mock() + client.call_tool = Mock(return_value={"success": True, "data": {}}) + client.list_tools = Mock(return_value={"tools": []}) + client.list_resources = Mock(return_value={"resources": []}) + return client + + +@pytest.fixture +def test_data() -> dict[str, Any]: + """Provide test data for testing.""" + return { + "user": { + "id": "test-user-123", + "name": "Test User", + "email": "test@example.com", + "roles": ["user"], + }, + "organization": { + "id": "test-org-123", + "name": "Test Organization", + "description": "Test organization for testing", + }, + "project": { + "id": "test-project-123", + "name": "Test Project", + "status": "active", + "organization_id": "test-org-123", + }, + "document": { + "id": "test-doc-123", + "title": "Test Document", + "content": "Test document content", + "project_id": "test-project-123", + }, + } + + +@pytest.fixture +def performance_monitor() -> Generator[dict[str, Any], None, None]: + """Monitor performance during test execution.""" + import time + + import psutil + + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data = { + "start_time": start_time, + "start_memory": start_memory, + "peak_memory": start_memory, + "measurements": [], + } + + def measure(name: str): + current_time = time.time() + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + measurement = { + "name": name, + "timestamp": current_time, + "memory": current_memory, + "duration": current_time - start_time, + } + + monitor_data["measurements"].append(measurement) + + monitor_data["peak_memory"] = max(monitor_data["peak_memory"], current_memory) + + return measurement + + monitor_data["measure"] = measure + + try: + yield monitor_data + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data["end_time"] = end_time + monitor_data["end_memory"] = end_memory + monitor_data["total_duration"] = end_time - start_time + monitor_data["memory_delta"] = end_memory - start_memory + + +@pytest.fixture +def security_context() -> dict[str, Any]: + """Provide security context for testing.""" + return { + "user_id": "test-user-123", + "organization_id": "test-org-123", + "roles": ["user"], + "permissions": ["read", "write"], + "auth_token": "test-token-123", + "session_id": "test-session-123", + } + + +@pytest.fixture +def mock_http_client() -> Mock: + """Create a mock HTTP client for testing.""" + client = Mock() + client.get = Mock(return_value=Mock(status_code=200, json=dict)) + client.post = Mock(return_value=Mock(status_code=201, json=dict)) + client.put = Mock(return_value=Mock(status_code=200, json=dict)) + client.delete = Mock(return_value=Mock(status_code=204, json=dict)) + return client + + +@pytest.fixture +def mock_database() -> Mock: + """Create a mock database for testing.""" + db = Mock() + db.execute = Mock(return_value=Mock(rowcount=1)) + db.fetchone = Mock(return_value={}) + db.fetchall = Mock(return_value=[]) + db.commit = Mock() + db.rollback = Mock() + return db + + +@pytest.fixture +def mock_cache() -> Mock: + """Create a mock cache for testing.""" + cache = Mock() + cache.get = Mock(return_value=None) + cache.set = Mock(return_value=True) + cache.delete = Mock(return_value=True) + cache.clear = Mock(return_value=True) + return cache + + +@pytest.fixture +def mock_message_broker() -> Mock: + """Create a mock message broker for testing.""" + broker = Mock() + broker.publish = Mock(return_value=True) + broker.subscribe = Mock(return_value=Mock()) + broker.unsubscribe = Mock(return_value=True) + return broker + + +@pytest.fixture +def mock_file_system() -> Mock: + """Create a mock file system for testing.""" + fs = Mock() + fs.exists = Mock(return_value=True) + fs.read_text = Mock(return_value="test content") + fs.write_text = Mock(return_value=10) + fs.mkdir = Mock(return_value=True) + fs.rmdir = Mock(return_value=True) + fs.unlink = Mock(return_value=True) + return fs + + +@pytest.fixture +def mock_logger() -> Mock: + """Create a mock logger for testing.""" + logger = Mock() + logger.debug = Mock() + logger.info = Mock() + logger.warning = Mock() + logger.error = Mock() + logger.critical = Mock() + return logger + + +@pytest.fixture +def mock_config() -> Mock: + """Create a mock configuration for testing.""" + config = Mock() + config.get = Mock(return_value="default_value") + config.set = Mock(return_value=True) + config.has = Mock(return_value=True) + return config + + +@pytest.fixture +def mock_environment() -> Generator[dict[str, str], None, None]: + """Create a mock environment for testing.""" + original_env = os.environ.copy() + + test_env = { + "TEST_MODE": "true", + "TEST_DATABASE_URL": "sqlite:///:memory:", + "TEST_CACHE_URL": "memory://", + "TEST_MESSAGE_BROKER_URL": "memory://", + } + + os.environ.update(test_env) + + try: + yield test_env + finally: + os.environ.clear() + os.environ.update(original_env) diff --git a/packages/phenokit-config-kit/config_kit/pytest/fixtures/performance.py b/packages/phenokit-config-kit/config_kit/pytest/fixtures/performance.py new file mode 100644 index 0000000..5c74ef9 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/fixtures/performance.py @@ -0,0 +1,183 @@ +""" +Performance testing fixtures for pytest. + +This module provides fixtures specifically for performance testing. +""" + +import threading +import time +from collections.abc import Generator +from contextlib import contextmanager +from typing import Any +from unittest.mock import Mock + +import psutil +import pytest + + +@pytest.fixture +def performance_monitor() -> Generator[dict[str, Any], None, None]: + """Monitor performance during test execution.""" + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data = { + "start_time": start_time, + "start_memory": start_memory, + "peak_memory": start_memory, + "measurements": [], + "threads": [], + } + + def measure(name: str, **kwargs): + current_time = time.time() + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + measurement = { + "name": name, + "timestamp": current_time, + "memory": current_memory, + "duration": current_time - start_time, + **kwargs, + } + + monitor_data["measurements"].append(measurement) + + monitor_data["peak_memory"] = max(monitor_data["peak_memory"], current_memory) + + return measurement + + def start_thread_monitoring(): + """Start background thread monitoring.""" + def monitor(): + while True: + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + monitor_data["peak_memory"] = max(monitor_data["peak_memory"], current_memory) + time.sleep(0.1) + + thread = threading.Thread(target=monitor, daemon=True) + thread.start() + monitor_data["threads"].append(thread) + + monitor_data["measure"] = measure + monitor_data["start_thread_monitoring"] = start_thread_monitoring + + try: + yield monitor_data + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data["end_time"] = end_time + monitor_data["end_memory"] = end_memory + monitor_data["total_duration"] = end_time - start_time + monitor_data["memory_delta"] = end_memory - start_memory + + +@pytest.fixture +def benchmark_data() -> Generator[dict[str, Any], None, None]: + """Provide benchmark data for testing.""" + return { + "iterations": 1000, + "warmup_iterations": 100, + "timeout": 60.0, + "threshold": 1.0, + "results": [], + } + + +@pytest.fixture +def load_test_data() -> Generator[dict[str, Any], None, None]: + """Provide load test data for testing.""" + return { + "users": 10, + "duration": 60, + "ramp_up": 10, + "ramp_down": 10, + "requests_per_second": 100, + "concurrent_requests": 50, + } + + +@pytest.fixture +def memory_profiler() -> Generator[dict[str, Any], None, None]: + """Provide memory profiling capabilities.""" + profiler_data = { + "snapshots": [], + "baseline": None, + "current": None, + } + + def take_snapshot(name: str): + snapshot = { + "name": name, + "timestamp": time.time(), + "memory": psutil.Process().memory_info().rss / 1024 / 1024, + "memory_percent": psutil.Process().memory_percent(), + "cpu_percent": psutil.Process().cpu_percent(), + } + profiler_data["snapshots"].append(snapshot) + profiler_data["current"] = snapshot + return snapshot + + def set_baseline(): + profiler_data["baseline"] = take_snapshot("baseline") + + def get_memory_delta(): + if profiler_data["baseline"] and profiler_data["current"]: + return profiler_data["current"]["memory"] - profiler_data["baseline"]["memory"] + return 0 + + profiler_data["take_snapshot"] = take_snapshot + profiler_data["set_baseline"] = set_baseline + profiler_data["get_memory_delta"] = get_memory_delta + + yield profiler_data + + +@pytest.fixture +def performance_thresholds() -> dict[str, float]: + """Provide performance thresholds for testing.""" + return { + "max_duration": 1.0, # seconds + "max_memory": 100.0, # MB + "max_cpu": 80.0, # percent + "max_memory_delta": 50.0, # MB + } + + +@pytest.fixture +def mock_performance_client() -> Mock: + """Create a mock performance client for testing.""" + client = Mock() + client.benchmark = Mock(return_value={"duration": 0.5, "memory": 50.0}) + client.profile = Mock(return_value={"cpu": 10.0, "memory": 50.0}) + client.load_test = Mock(return_value={"throughput": 100.0, "latency": 0.1}) + return client + + +@contextmanager +def performance_context(name: str, threshold: float = 1.0): + """Context manager for performance monitoring.""" + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + try: + yield + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + duration = end_time - start_time + memory_delta = end_memory - start_memory + + if duration > threshold: + pytest.fail(f"Performance threshold exceeded for {name}: {duration:.2f}s > {threshold}s") + + print(f"Performance: {name} - Duration: {duration:.2f}s, Memory: {memory_delta:.2f}MB") + + +@pytest.fixture +def performance_context_factory(): + """Factory for creating performance contexts.""" + return performance_context diff --git a/packages/phenokit-config-kit/config_kit/pytest/fixtures/security.py b/packages/phenokit-config-kit/config_kit/pytest/fixtures/security.py new file mode 100644 index 0000000..e2f5d5e --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/fixtures/security.py @@ -0,0 +1,196 @@ +""" +Security testing fixtures for pytest. + +This module provides fixtures specifically for security testing. +""" + +import hashlib +import hmac +from collections.abc import Generator +from typing import Any +from unittest.mock import Mock + +import pytest + + +@pytest.fixture +def security_context() -> Generator[dict[str, Any], None, None]: + """Provide security context for testing.""" + return { + "user_id": "test-user-123", + "organization_id": "test-org-123", + "roles": ["user"], + "permissions": ["read", "write"], + "auth_token": "test-token-123", + "session_id": "test-session-123", + "ip_address": "127.0.0.1", + "user_agent": "test-agent", + "request_id": "test-request-123", + } + + +@pytest.fixture +def mock_auth_client() -> Mock: + """Create a mock authentication client for testing.""" + client = Mock() + client.authenticate = Mock(return_value={"success": True, "user_id": "test-user-123"}) + client.authorize = Mock(return_value={"success": True, "permissions": ["read", "write"]}) + client.validate_token = Mock(return_value={"valid": True, "user_id": "test-user-123"}) + client.refresh_token = Mock(return_value={"success": True, "token": "new-token-123"}) + client.logout = Mock(return_value={"success": True}) + return client + + +@pytest.fixture +def mock_security_scanner() -> Mock: + """Create a mock security scanner for testing.""" + scanner = Mock() + scanner.scan_vulnerabilities = Mock(return_value={"vulnerabilities": []}) + scanner.scan_dependencies = Mock(return_value={"vulnerabilities": []}) + scanner.scan_secrets = Mock(return_value={"secrets": []}) + scanner.scan_code = Mock(return_value={"issues": []}) + return scanner + + +@pytest.fixture +def test_credentials() -> dict[str, str]: + """Provide test credentials for testing.""" + return { + "username": "testuser", + "password": "testpassword123", + "email": "test@example.com", + "api_key": "test-api-key-123", + "secret": "test-secret-123", + } + + +@pytest.fixture +def test_tokens() -> dict[str, str]: + """Provide test tokens for testing.""" + return { + "access_token": "test-access-token-123", + "refresh_token": "test-refresh-token-123", + "id_token": "test-id-token-123", + "csrf_token": "test-csrf-token-123", + } + + +@pytest.fixture +def test_hashes() -> dict[str, str]: + """Provide test hashes for testing.""" + test_string = "test-string-123" + return { + "md5": hashlib.md5(test_string.encode()).hexdigest(), + "sha1": hashlib.sha1(test_string.encode()).hexdigest(), + "sha256": hashlib.sha256(test_string.encode()).hexdigest(), + "sha512": hashlib.sha512(test_string.encode()).hexdigest(), + "hmac": hmac.new(b"secret-key", test_string.encode(), hashlib.sha256).hexdigest(), + } + + +@pytest.fixture +def test_encryption_data() -> dict[str, Any]: + """Provide test encryption data for testing.""" + return { + "plaintext": "test-plaintext-123", + "ciphertext": "encrypted-test-data-123", + "key": "test-encryption-key-123", + "iv": "test-initialization-vector-123", + "algorithm": "AES-256-GCM", + } + + +@pytest.fixture +def test_sql_injection_payloads() -> list[str]: + """Provide test SQL injection payloads for testing.""" + return [ + "'; DROP TABLE users; --", + "1' OR '1'='1", + "1' UNION SELECT * FROM users --", + "1'; INSERT INTO users (username, password) VALUES ('hacker', 'password'); --", + "1' AND (SELECT COUNT(*) FROM users) > 0 --", + ] + + +@pytest.fixture +def test_xss_payloads() -> list[str]: + """Provide test XSS payloads for testing.""" + return [ + "", + "", + "", + "javascript:alert('XSS')", + "", + ] + + +@pytest.fixture +def test_csrf_payloads() -> list[str]: + """Provide test CSRF payloads for testing.""" + return [ + "
", + "
", + "", + ] + + +@pytest.fixture +def test_path_traversal_payloads() -> list[str]: + """Provide test path traversal payloads for testing.""" + return [ + "../../../etc/passwd", + "..\\..\\..\\windows\\system32\\drivers\\etc\\hosts", + "....//....//....//etc/passwd", + "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", + ] + + +@pytest.fixture +def test_command_injection_payloads() -> list[str]: + """Provide test command injection payloads for testing.""" + return [ + "; ls -la", + "| cat /etc/passwd", + "& whoami", + "`id`", + "$(id)", + "; rm -rf /", + ] + + +@pytest.fixture +def security_headers() -> dict[str, str]: + """Provide security headers for testing.""" + return { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "Content-Security-Policy": "default-src 'self'", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "geolocation=(), microphone=(), camera=()", + } + + +@pytest.fixture +def mock_encryption_service() -> Mock: + """Create a mock encryption service for testing.""" + service = Mock() + service.encrypt = Mock(return_value="encrypted-data-123") + service.decrypt = Mock(return_value="decrypted-data-123") + service.generate_key = Mock(return_value="generated-key-123") + service.generate_iv = Mock(return_value="generated-iv-123") + return service + + +@pytest.fixture +def mock_audit_logger() -> Mock: + """Create a mock audit logger for testing.""" + logger = Mock() + logger.log_auth_success = Mock() + logger.log_auth_failure = Mock() + logger.log_permission_denied = Mock() + logger.log_security_event = Mock() + logger.log_data_access = Mock() + logger.log_data_modification = Mock() + return logger diff --git a/packages/phenokit-config-kit/config_kit/pytest/plugins/__init__.py b/packages/phenokit-config-kit/config_kit/pytest/plugins/__init__.py new file mode 100644 index 0000000..1fe1989 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/plugins/__init__.py @@ -0,0 +1,15 @@ +""" +Pytest plugins for config-kit package. + +This module provides a collection of pytest plugins that can be used +across different Python projects. +""" + +__version__ = "0.1.0" +__all__ = [ + "ArchitecturePlugin", + "CoveragePlugin", + "PerformancePlugin", + "ReportingPlugin", + "SecurityPlugin", +] diff --git a/packages/phenokit-config-kit/config_kit/pytest/plugins/architecture.py b/packages/phenokit-config-kit/config_kit/pytest/plugins/architecture.py new file mode 100644 index 0000000..ecd9519 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/plugins/architecture.py @@ -0,0 +1,271 @@ +""" +Architecture testing plugin for pytest. + +This plugin provides architecture fitness tests including: +- File size validation +- Import boundary enforcement +- Dependency direction validation +- Naming convention checks +- Cyclomatic complexity analysis +""" + +import ast +import os +from pathlib import Path + +import pytest + + +class ArchitecturePlugin: + """Pytest plugin for architecture fitness testing.""" + + def __init__(self, config): + self.config = config + self.max_file_size = config.getoption("--max-file-size", default=1000) + self.max_complexity = config.getoption("--max-complexity", default=10) + self.enforce_imports = config.getoption("--enforce-imports", default=True) + self.enforce_dependencies = config.getoption("--enforce-dependencies", default=True) + + @pytest.hookimpl(tryfirst=True) + def pytest_collection_modifyitems(self, config, items): + """Add architecture tests to the test collection.""" + if not self.enforce_imports and not self.enforce_dependencies: + return + + # Add architecture tests + architecture_items = [] + + if self.enforce_imports: + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_import_boundaries", + callobj=self._test_import_boundaries, + markers=[pytest.mark.architecture, pytest.mark.imports], + ), + ) + + if self.enforce_dependencies: + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_dependency_direction", + callobj=self._test_dependency_direction, + markers=[pytest.mark.architecture, pytest.mark.dependencies], + ), + ) + + # Add file size tests + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_file_sizes", + callobj=self._test_file_sizes, + markers=[pytest.mark.architecture, pytest.mark.file_size], + ), + ) + + # Add complexity tests + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_cyclomatic_complexity", + callobj=self._test_cyclomatic_complexity, + markers=[pytest.mark.architecture, pytest.mark.complexity], + ), + ) + + items.extend(architecture_items) + + def _test_import_boundaries(self): + """Test that import boundaries are respected.""" + violations = [] + + # Define allowed import patterns + allowed_patterns = [ + r"^src\.", + r"^tests\.", + r"^conftest$", + ] + + # Check each Python file + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if not self._is_allowed_import(alias.name, allowed_patterns): + violations.append(f"{py_file}: {alias.name}") + elif isinstance(node, ast.ImportFrom): + if node.module and not self._is_allowed_import(node.module, allowed_patterns): + violations.append(f"{py_file}: {node.module}") + + if violations: + pytest.fail("Import boundary violations found:\n" + "\n".join(violations)) + + def _test_dependency_direction(self): + """Test that dependency direction is respected.""" + violations = [] + + # Define dependency rules + dependency_rules = { + "src.domain": [], # Domain should not depend on anything + "src.application": ["src.domain"], # Application can depend on domain + "src.adapters": ["src.domain", "src.application"], # Adapters can depend on domain and application + "src.infrastructure": ["src.domain", "src.application", "src.adapters"], # Infrastructure can depend on all + } + + for py_file in self._get_python_files(): + file_path = str(py_file) + if not file_path.startswith("src/"): + continue + + # Determine which layer this file belongs to + file_layer = self._get_file_layer(file_path) + if not file_layer: + continue + + # Check imports against dependency rules + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + import_layer = self._get_import_layer(node.module) + if import_layer and not self._is_allowed_dependency(file_layer, import_layer, dependency_rules): + violations.append(f"{py_file}: {file_layer} -> {import_layer} ({node.module})") + + if violations: + pytest.fail("Dependency direction violations found:\n" + "\n".join(violations)) + + def _test_file_sizes(self): + """Test that file sizes are within limits.""" + violations = [] + + for py_file in self._get_python_files(): + line_count = sum(1 for _ in open(py_file, encoding="utf-8")) + if line_count > self.max_file_size: + violations.append(f"{py_file}: {line_count} lines (max: {self.max_file_size})") + + if violations: + pytest.fail("File size violations found:\n" + "\n".join(violations)) + + def _test_cyclomatic_complexity(self): + """Test that cyclomatic complexity is within limits.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + complexity = self._calculate_complexity(node) + if complexity > self.max_complexity: + violations.append(f"{py_file}:{node.lineno} {node.name}: {complexity} (max: {self.max_complexity})") + + if violations: + pytest.fail("Cyclomatic complexity violations found:\n" + "\n".join(violations)) + + def _get_python_files(self) -> list[Path]: + """Get all Python files in the project.""" + python_files = [] + for root, dirs, files in os.walk("."): + # Skip certain directories + dirs[:] = [d for d in dirs if d not in {".git", "__pycache__", ".pytest_cache", "htmlcov", "dist", "build"}] + + for file in files: + if file.endswith(".py"): + python_files.append(Path(root) / file) + + return python_files + + def _is_allowed_import(self, import_name: str, allowed_patterns: list[str]) -> bool: + """Check if an import is allowed based on patterns.""" + import re + return any(re.match(pattern, import_name) for pattern in allowed_patterns) + + def _get_file_layer(self, file_path: str) -> str | None: + """Determine which architectural layer a file belongs to.""" + if "src/domain" in file_path: + return "src.domain" + if "src/application" in file_path: + return "src.application" + if "src/adapters" in file_path: + return "src.adapters" + if "src/infrastructure" in file_path: + return "src.infrastructure" + return None + + def _get_import_layer(self, import_name: str) -> str | None: + """Determine which architectural layer an import belongs to.""" + if import_name.startswith("src.domain"): + return "src.domain" + if import_name.startswith("src.application"): + return "src.application" + if import_name.startswith("src.adapters"): + return "src.adapters" + if import_name.startswith("src.infrastructure"): + return "src.infrastructure" + return None + + def _is_allowed_dependency(self, from_layer: str, to_layer: str, rules: dict[str, list[str]]) -> bool: + """Check if a dependency from one layer to another is allowed.""" + allowed_deps = rules.get(from_layer, []) + return to_layer in allowed_deps + + def _calculate_complexity(self, node: ast.AST) -> 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)) or isinstance(child, ast.ExceptHandler): + complexity += 1 + elif isinstance(child, ast.BoolOp): + complexity += len(child.values) - 1 + + return complexity + + +def pytest_addoption(parser): + """Add command line options for architecture testing.""" + parser.addoption( + "--max-file-size", + type=int, + default=1000, + help="Maximum allowed lines per file", + ) + parser.addoption( + "--max-complexity", + type=int, + default=10, + help="Maximum allowed cyclomatic complexity", + ) + parser.addoption( + "--enforce-imports", + action="store_true", + default=True, + help="Enforce import boundaries", + ) + parser.addoption( + "--enforce-dependencies", + action="store_true", + default=True, + help="Enforce dependency direction", + ) + + +def pytest_configure(config): + """Configure the architecture plugin.""" + config.pluginmanager.register(ArchitecturePlugin(config), "architecture") diff --git a/packages/phenokit-config-kit/config_kit/pytest/plugins/performance.py b/packages/phenokit-config-kit/config_kit/pytest/plugins/performance.py new file mode 100644 index 0000000..268ddc3 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/plugins/performance.py @@ -0,0 +1,166 @@ +""" +Performance testing plugin for pytest. + +This plugin provides performance testing capabilities including: +- Benchmark testing +- Memory profiling +- Performance regression detection +- Load testing utilities +""" + +import threading +import time +from collections.abc import Callable +from contextlib import contextmanager + +import psutil +import pytest + + +class PerformancePlugin: + """Pytest plugin for performance testing.""" + + def __init__(self, config): + self.config = config + self.benchmark_threshold = config.getoption("--benchmark-threshold", default=1.0) + self.memory_threshold = config.getoption("--memory-threshold", default=100) # MB + self.performance_data = {} + + @pytest.hookimpl(tryfirst=True) + def pytest_runtest_setup(self, item): + """Setup performance monitoring for each test.""" + if hasattr(item, "get_closest_marker") and item.get_closest_marker("performance"): + self.performance_data[item.nodeid] = { + "start_time": time.time(), + "start_memory": psutil.Process().memory_info().rss / 1024 / 1024, # MB + "peak_memory": 0, + } + + @pytest.hookimpl(tryfirst=True) + def pytest_runtest_teardown(self, item): + """Teardown performance monitoring for each test.""" + if item.nodeid in self.performance_data: + data = self.performance_data[item.nodeid] + data["end_time"] = time.time() + data["duration"] = data["end_time"] - data["start_time"] + data["end_memory"] = psutil.Process().memory_info().rss / 1024 / 1024 # MB + data["memory_delta"] = data["end_memory"] - data["start_memory"] + + @pytest.hookimpl(tryfirst=True) + def pytest_runtest_call(self, item): + """Monitor performance during test execution.""" + if item.nodeid in self.performance_data: + # Monitor memory usage during test execution + def monitor_memory(): + while item.nodeid in self.performance_data: + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + self.performance_data[item.nodeid]["peak_memory"] = max(self.performance_data[item.nodeid]["peak_memory"], current_memory) + time.sleep(0.1) + + monitor_thread = threading.Thread(target=monitor_memory, daemon=True) + monitor_thread.start() + + def pytest_collection_modifyitems(self, config, items): + """Add performance validation tests.""" + performance_items = [] + + # Add benchmark validation + performance_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_benchmark_performance", + callobj=self._test_benchmark_performance, + markers=[pytest.mark.performance, pytest.mark.benchmark], + ), + ) + + # Add memory usage validation + performance_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_memory_usage", + callobj=self._test_memory_usage, + markers=[pytest.mark.performance, pytest.mark.memory], + ), + ) + + items.extend(performance_items) + + def _test_benchmark_performance(self): + """Test that benchmark performance meets thresholds.""" + violations = [] + + for test_id, data in self.performance_data.items(): + if "duration" in data and data["duration"] > self.benchmark_threshold: + violations.append(f"{test_id}: {data['duration']:.2f}s (threshold: {self.benchmark_threshold}s)") + + if violations: + pytest.fail("Benchmark performance violations found:\n" + "\n".join(violations)) + + def _test_memory_usage(self): + """Test that memory usage is within thresholds.""" + violations = [] + + for test_id, data in self.performance_data.items(): + if "peak_memory" in data and data["peak_memory"] > self.memory_threshold: + violations.append(f"{test_id}: {data['peak_memory']:.2f}MB (threshold: {self.memory_threshold}MB)") + + if violations: + pytest.fail("Memory usage violations found:\n" + "\n".join(violations)) + + +@contextmanager +def performance_monitor(test_name: str, threshold: float = 1.0): + """Context manager for monitoring performance of code blocks.""" + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + try: + yield + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + duration = end_time - start_time + memory_delta = end_memory - start_memory + + if duration > threshold: + pytest.fail(f"Performance threshold exceeded: {duration:.2f}s > {threshold}s") + + print(f"Performance: {test_name} - Duration: {duration:.2f}s, Memory: {memory_delta:.2f}MB") + + +def benchmark_test(func: Callable) -> Callable: + """Decorator for marking functions as benchmark tests.""" + return pytest.mark.performance(func) + + +def memory_test(func: Callable) -> Callable: + """Decorator for marking functions as memory tests.""" + return pytest.mark.memory(func) + + +def load_test(users: int = 10, duration: int = 60) -> Callable: + """Decorator for marking functions as load tests.""" + return pytest.mark.performance(pytest.mark.parametrize("load_users", [users])(func)) + + +def pytest_addoption(parser): + """Add command line options for performance testing.""" + parser.addoption( + "--benchmark-threshold", + type=float, + default=1.0, + help="Maximum allowed execution time for benchmark tests (seconds)", + ) + parser.addoption( + "--memory-threshold", + type=float, + default=100, + help="Maximum allowed memory usage (MB)", + ) + + +def pytest_configure(config): + """Configure the performance plugin.""" + config.pluginmanager.register(PerformancePlugin(config), "performance") diff --git a/packages/phenokit-config-kit/config_kit/pytest/plugins/security.py b/packages/phenokit-config-kit/config_kit/pytest/plugins/security.py new file mode 100644 index 0000000..b1d9690 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/plugins/security.py @@ -0,0 +1,243 @@ +""" +Security testing plugin for pytest. + +This plugin provides security testing capabilities including: +- Security vulnerability scanning +- Authentication testing +- Authorization testing +- Input validation testing +- Security compliance checking +""" + +import ast +import re +from pathlib import Path + +import pytest + + +class SecurityPlugin: + """Pytest plugin for security testing.""" + + def __init__(self, config): + self.config = config + self.security_rules = self._load_security_rules() + self.vulnerability_patterns = self._load_vulnerability_patterns() + + def _load_security_rules(self) -> dict[str, list[str]]: + """Load security rules configuration.""" + return { + "forbidden_imports": [ + "pickle", + "marshal", + "shelve", + "dbm", + "sqlite3", + "subprocess", + "os.system", + "eval", + "exec", + "compile", + ], + "forbidden_functions": [ + "eval", + "exec", + "compile", + "input", + "raw_input", + "reload", + "__import__", + ], + "required_imports": [ + "hashlib", + "secrets", + "hmac", + ], + "password_patterns": [ + r"password\s*=\s*['\"][^'\"]+['\"]", + r"passwd\s*=\s*['\"][^'\"]+['\"]", + r"pwd\s*=\s*['\"][^'\"]+['\"]", + ], + "hardcoded_secrets": [ + r"api_key\s*=\s*['\"][^'\"]+['\"]", + r"secret\s*=\s*['\"][^'\"]+['\"]", + r"token\s*=\s*['\"][^'\"]+['\"]", + r"key\s*=\s*['\"][^'\"]+['\"]", + ], + } + + def _load_vulnerability_patterns(self) -> dict[str, str]: + """Load vulnerability detection patterns.""" + return { + "sql_injection": r"(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER).*\+.*%", + "xss": r".*", + "path_traversal": r"\.\./", + "command_injection": r"(os\.system|subprocess\.call|subprocess\.run)", + "unsafe_deserialization": r"(pickle\.loads|marshal\.loads)", + "weak_crypto": r"(md5|sha1|des|rc4)", + "hardcoded_credentials": r"(username|password|api_key|secret)\s*=\s*['\"][^'\"]+['\"]", + } + + @pytest.hookimpl(tryfirst=True) + def pytest_collection_modifyitems(self, config, items): + """Add security validation tests.""" + security_items = [] + + # Add vulnerability scanning + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_vulnerability_scan", + callobj=self._test_vulnerability_scan, + markers=[pytest.mark.security, pytest.mark.vulnerability], + ), + ) + + # Add forbidden imports check + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_forbidden_imports", + callobj=self._test_forbidden_imports, + markers=[pytest.mark.security, pytest.mark.imports], + ), + ) + + # Add hardcoded secrets check + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_hardcoded_secrets", + callobj=self._test_hardcoded_secrets, + markers=[pytest.mark.security, pytest.mark.secrets], + ), + ) + + # Add password security check + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_password_security", + callobj=self._test_password_security, + markers=[pytest.mark.security, pytest.mark.passwords], + ), + ) + + items.extend(security_items) + + def _test_vulnerability_scan(self): + """Scan code for common vulnerabilities.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + content = f.read() + + for vuln_type, pattern in self.vulnerability_patterns.items(): + matches = re.findall(pattern, content, re.IGNORECASE) + if matches: + violations.append(f"{py_file}: {vuln_type} - {matches}") + + if violations: + pytest.fail("Security vulnerabilities found:\n" + "\n".join(violations)) + + def _test_forbidden_imports(self): + """Check for forbidden imports that could be security risks.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name in self.security_rules["forbidden_imports"]: + violations.append(f"{py_file}: Forbidden import '{alias.name}'") + elif isinstance(node, ast.ImportFrom): + if node.module in self.security_rules["forbidden_imports"]: + violations.append(f"{py_file}: Forbidden import '{node.module}'") + + if violations: + pytest.fail("Forbidden imports found:\n" + "\n".join(violations)) + + def _test_hardcoded_secrets(self): + """Check for hardcoded secrets in the code.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + content = f.read() + + for pattern in self.security_rules["hardcoded_secrets"]: + matches = re.findall(pattern, content, re.IGNORECASE) + if matches: + violations.append(f"{py_file}: Hardcoded secret - {matches}") + + if violations: + pytest.fail("Hardcoded secrets found:\n" + "\n".join(violations)) + + def _test_password_security(self): + """Check for insecure password handling.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + content = f.read() + + for pattern in self.security_rules["password_patterns"]: + matches = re.findall(pattern, content, re.IGNORECASE) + if matches: + violations.append(f"{py_file}: Insecure password handling - {matches}") + + if violations: + pytest.fail("Insecure password handling found:\n" + "\n".join(violations)) + + def _get_python_files(self) -> list[Path]: + """Get all Python files in the project.""" + python_files = [] + for root, dirs, files in Path().rglob("*.py"): + # Skip certain directories + if any(skip in str(root) for skip in (".git", "__pycache__", ".pytest_cache", "htmlcov", "dist", "build")): + continue + python_files.append(root) + + return python_files + + +def security_test(func: Callable) -> Callable: + """Decorator for marking functions as security tests.""" + return pytest.mark.security(func) + + +def auth_test(func: Callable) -> Callable: + """Decorator for marking functions as authentication tests.""" + return pytest.mark.auth(func) + + +def injection_test(func: Callable) -> Callable: + """Decorator for marking functions as injection tests.""" + return pytest.mark.injection(func) + + +def xss_test(func: Callable) -> Callable: + """Decorator for marking functions as XSS tests.""" + return pytest.mark.xss(func) + + +def csrf_test(func: Callable) -> Callable: + """Decorator for marking functions as CSRF tests.""" + return pytest.mark.csrf(func) + + +def sql_injection_test(func: Callable) -> Callable: + """Decorator for marking functions as SQL injection tests.""" + return pytest.mark.sql_injection(func) + + +def pytest_configure(config): + """Configure the security plugin.""" + config.pluginmanager.register(SecurityPlugin(config), "security") diff --git a/packages/phenokit-config-kit/linting/.editorconfig b/packages/phenokit-config-kit/linting/.editorconfig new file mode 100644 index 0000000..0088cc5 --- /dev/null +++ b/packages/phenokit-config-kit/linting/.editorconfig @@ -0,0 +1,40 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +# Python files +[*.py] +indent_size = 4 +max_line_length = 88 + +# YAML files +[*.{yaml,yml}] +indent_size = 2 +max_line_length = 88 + +# Markdown files +[*.md] +trim_trailing_whitespace = false +max_line_length = 88 + +# JSON files +[*.json] +indent_size = 2 + +# Shell scripts +[*.sh] +indent_size = 4 + +# Dockerfile +[Dockerfile] +indent_size = 4 diff --git a/packages/phenokit-config-kit/linting/.markdownlint.json b/packages/phenokit-config-kit/linting/.markdownlint.json new file mode 100644 index 0000000..9cee890 --- /dev/null +++ b/packages/phenokit-config-kit/linting/.markdownlint.json @@ -0,0 +1,45 @@ +{ + "default": true, + "MD001": false, + "MD013": { + "line_length": 88 + }, + "MD024": { + "allow_different_nesting": true + }, + "MD033": false, + "MD034": false, + "MD036": false, + "MD037": false, + "MD038": false, + "MD039": false, + "MD040": false, + "MD041": false, + "MD042": false, + "MD043": false, + "MD044": false, + "MD045": false, + "MD046": false, + "MD047": false, + "MD048": { + "level": "warning" + }, + "MD049": { + "level": "warning" + }, + "MD050": { + "level": "warning" + }, + "MD051": { + "level": "warning" + }, + "MD052": { + "level": "warning" + }, + "MD053": { + "level": "warning" + }, + "MD054": { + "level": "warning" + } +} diff --git a/packages/phenokit-config-kit/linting/README.md b/packages/phenokit-config-kit/linting/README.md new file mode 100644 index 0000000..6d2570a --- /dev/null +++ b/packages/phenokit-config-kit/linting/README.md @@ -0,0 +1,225 @@ +# Linting Configurations for config-kit + +This directory contains comprehensive linting configurations for the config-kit package, including all major linting tools, pre-commit hooks, and CI/CD integration. + +## Overview + +The linting configuration includes: + +- **Code Style**: Black, isort, Ruff +- **Type Checking**: MyPy +- **Security**: Bandit, Safety, detect-secrets +- **Documentation**: pydocstyle, interrogate, docformatter +- **Performance**: Radon (complexity) +- **Quality**: Vulture (dead code), PyUp (upgrade checks) +- **YAML/Markdown**: yamllint, markdownlint +- **Shell**: shellcheck +- **Pre-commit Hooks**: Automated checks on commit +- **CI/CD Integration**: Quality gates and automated checks + +## Directory Structure + +``` +linting/ +├── pyproject.toml # Main linting configuration +├── linters.toml # Centralized linter configuration +├── .editorconfig # Editor configuration +├── .markdownlint.json # Markdown linting rules +├── pre-commit-hooks.yaml # Pre-commit configuration +├── ci-cd/ +│ ├── gatekeeper.toml # Quality gate definitions +│ └── scripts/ +│ └── quality-gate.sh # Comprehensive quality check script +├── git-hooks/ # Custom git hooks +│ ├── pre-commit # Custom pre-commit hook +│ └── commit-msg # Commit message validation +└── README.md # This file +``` + +## Usage + +### 1. Basic Linting + +Run individual linters: + +```bash +# Code formatting +black . +black --check # Check without modifying + +# Import sorting +isort . +isort --check-only + +# Ruff (all-in-one) +ruff check . +ruff check --fix # Fix auto-fixable issues +ruff format . # Format code + +# Type checking +mypy src/ + +# Security scanning +bandit -r src/ + +# Documentation +pydocstyle src/ +interrogate src/ +``` + +### 2. Pre-commit Hooks + +Set up pre-commit hooks: + +```bash +# Install pre-commit +pip install pre-commit + +# Install hooks +pre-commit install + +# Run hooks manually on all files +pre-commit run --all-files +``` + +### 3. Comprehensive Quality Gate + +Run all quality checks: + +```bash +# Run comprehensive quality gate +./linting/ci-cd/scripts/quality-gate.sh + +# Run specific sections +./linting/ci-cd/scripts/quality-gate.sh 2>&1 | grep "LINTING CHECKS" +``` + +### 4. CI/CD Integration + +The quality gate configuration can be integrated into CI/CD pipelines: + +```yaml +# Example GitHub Actions workflow +- name: Run quality gate + run: ./linting/ci-cd/scripts/quality-gate.sh + +- name: Upload quality reports + uses: actions/upload-artifact@v3 + if: always() + with: + name: quality-reports + path: | + quality_gate_results.json + bandit-report.json + safety-report.json +``` + +## Configuration Details + +### pyproject.toml + +Contains configuration for all Python-based linters: + +- **Ruff**: Fast Python linter and formatter +- **Black**: Code formatter +- **isort**: Import sorter +- **MyPy**: Type checker +- **Bandit**: Security linter +- **Safety**: Vulnerability scanner +- **yamllint**: YAML linter +- **markdownlint**: Markdown linter +- **pydocstyle**: Docstring checker +- **interrogate**: Coverage checker for docstrings +- **vulture**: Dead code finder +- **radon**: Code complexity analyzer + +### Pre-commit Hooks + +The pre-commit configuration includes: + +1. **Automatic formatting**: Black, isort, Ruff +2. **Type checking**: MyPy +3. **Security scanning**: Bandit +4. **Documentation**: pydocstyle, interrogate +5. **File-specific**: yamllint, markdownlint, shellcheck + +### Quality Gates + +The quality gate system defines thresholds for: + +- **Code Coverage**: 80% minimum +- **Documentation**: 80% docstring coverage +- **Security**: Zero critical/high vulnerabilities +- **Complexity**: Max 10 cyclomatic complexity +- **File Size**: Max 50KB per file +- **Dependencies**: No known vulnerabilities + +## Customization + +### Adding New Linters + +1. Add linter to `pyproject.toml` +2. Update `pre-commit-hooks.yaml` +3. Add checks to `quality-gate.sh` +4. Update `gatekeeper.toml` + +### Adjusting Rules + +Edit the respective configuration files: + +- **Python linters**: `pyproject.toml` +- **Markdown**: `.markdownlint.json` +- **YAML**: `yamllint` configuration in `pyproject.toml` +- **Quality gates**: `gatekeeper.toml` + +### Excluding Files + +Use `exclude` patterns in configurations: + +```toml +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["ARG001"] +"__init__.py" = ["F401"] +``` + +## Troubleshooting + +### Common Issues + +1. **Pre-commit hooks not running**: Ensure `pre-commit install` was run +2. **Linter dependencies missing**: Run `pip install -e .` +3. **Quality gate failing**: Check `quality_gate_results.json` for details +4. **Configuration conflicts**: Resolve by updating specific tool configurations + +### Debug Mode + +Run linters with verbose output: + +```bash +# Ruff verbose +ruff check --verbose + +# Black verbose +black --verbose + +# MyPy verbose +mypy --verbose +``` + +## Maintenance + +- Update linter versions regularly in `pre-commit-hooks.yaml` +- Monitor new rule releases in `pyproject.toml` +- Review quality gate thresholds in `gatekeeper.toml` +- Keep dependencies updated with `pip-compile` + +## Integration with Other Projects + +This linting configuration can be used as a template for other projects by: + +1. Copying the `linting/` directory +2. Adjusting paths and exclusions +3. Updating quality gate thresholds as needed +4. Integrating with project-specific requirements + +For full integration, ensure the configuration is included in the package data and properly referenced in each project's pyproject.toml. diff --git a/packages/phenokit-config-kit/linting/ci-cd/gatekeeper.toml b/packages/phenokit-config-kit/linting/ci-cd/gatekeeper.toml new file mode 100644 index 0000000..cb77fa8 --- /dev/null +++ b/packages/phenokit-config-kit/linting/ci-cd/gatekeeper.toml @@ -0,0 +1,93 @@ +# CI/CD Gatekeeper Configuration for config-kit +# Defines quality gates that must pass before merging/merging + +[gates.linting] +enabled = true +required = ["ruff", "black", "isort", "mypy"] +failure_threshold = 0 + +[gates.security] +enabled = true +required = ["bandit", "safety"] +failure_threshold = 0 +exclude_patterns = ["**/test_*.py", "**/tests/**"] + +[gates.documentation] +enabled = true +required = ["pydocstyle", "interrogate"] +failure_threshold = 0 +minimum_coverage = 80 +exclude_patterns = ["**/test_*.py", "**/tests/**"] + +[gates.performance] +enabled = false +required = [] +failure_threshold = 0 +max_complexity = 10 +max_file_size = 50000 + +[gates.test_coverage] +enabled = true +required_coverage = 80 +line_coverage = 80 +branch_coverage = 75 +function_coverage = 80 +exclude_patterns = ["**/test_*.py", "**/tests/**"] + +[gates.dependency_vulnerability] +enabled = true +fail_on_vulnerability = true +severity_threshold = "medium" +exclude_patterns = ["**/test_*.py", "**/tests/**"] + +[gates.code_patterns] +enabled = true +forbidden_patterns = [ + "import (os\.system|subprocess\.call|subprocess\.run|subprocess\.Popen)", + "eval\(", + "exec\(", + "print\(", + "TODO: FIX ME", + "FIXME:", + "HACK:", + "PRAGMA: disable" +] +required_patterns = [ + "assert ", + "raise ", + "import typing" +] +failure_threshold = 0 + +[gates.license_compliance] +enabled = true +allowed_licenses = ["MIT", "Apache-2.0", "BSD-3-Clause", "PSF", "CC0-1.0"] +blacklisted_packages = [] +failure_threshold = 0 + +[gates.metrics] +enabled = true +max_lines_per_file = 500 +max_functions_per_file = 20 +max_arguments_per_function = 7 +max_depth_nesting = 4 +failure_threshold = 0 + +[gates.git_checks] +enabled = true +no_merge_conflicts = true +no_private_keys = true +no_secrets = true +failure_threshold = 0 + +[reporting] +format = "json" +output_file = "quality_gate_report.json" +email_on_failure = false +slack_webhook = "" +require_all_gates = false + +[environments] +development = ["linting", "security"] +staging = ["linting", "security", "documentation", "test_coverage", "dependency_vulnerability"] +production = ["all"] diff --git a/packages/phenokit-config-kit/linting/ci-cd/scripts/quality-gate.sh b/packages/phenokit-config-kit/linting/ci-cd/scripts/quality-gate.sh new file mode 100755 index 0000000..511fe62 --- /dev/null +++ b/packages/phenokit-config-kit/linting/ci-cd/scripts/quality-gate.sh @@ -0,0 +1,243 @@ +#!/bin/bash + +# Quality Gate Script for config-kit +# This script runs all quality checks and exits with non-zero if any fail + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Global variables +PASSED=0 +FAILED=0 +TOTAL=0 +RESULTS_FILE="quality_gate_results.json" + +# Function to print colored output +print_header() { + echo -e "${BLUE}[$1]${NC} $2" +} + +print_success() { + echo -e "${GREEN}✓ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠ $1${NC}" +} + +print_error() { + echo -e "${RED}✗ $1${NC}" +} + +# Function to initialize results JSON +init_results() { + cat > "$RESULTS_FILE" << EOF +{ + "timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", + "project": "config-kit", + "checks": {} +} +EOF +} + +# Function to add check result to JSON +add_check_result() { + local check_name="$1" + local status="$2" + local message="$3" + local details="$4" + + # Use jq to update the JSON file + if command_exists jq; then + jq --arg name "$check_name" --arg status "$status" --arg message "$message" --arg details "$details" ' + .checks[$name] = { + "status": $status, + "message": $message, + "details": $details, + "timestamp": now + }' "$RESULTS_FILE" > temp.json && mv temp.json "$RESULTS_FILE" + fi +} + +# Function to run check and count results +run_check() { + local check_name="$1" + local check_command="$2" + local success_message="$3" + local failure_message="$4" + + TOTAL=$((TOTAL + 1)) + print_header "RUNNING" "$check_name" + + if eval "$check_command" >/dev/null 2>&1; then + PASSED=$((PASSED + 1)) + print_success "$success_message" + add_check_result "$check_name" "passed" "$success_message" "" + return 0 + else + FAILED=$((FAILED + 1)) + print_error "$failure_message" + add_check_result "$check_name" "failed" "$failure_message" "" + return 1 + fi +} + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Linting checks +run_linting_checks() { + print_header "SECTION" "Linting Checks" + + run_check "Ruff" "ruff check --exit-zero" "Ruff passed" "Ruff failed" + run_check "Black formatting" "black --check ." "Black format passed" "Black format failed" + run_check "Import sorting" "isort --check-only ." "isort passed" "isort failed" + run_check "Type checking" "mypy --ignore-missing-imports src/" "Mypy passed" "Mypy failed" +} + +# Security checks +run_security_checks() { + print_header "SECTION" "Security Checks" + + run_check "Bandit security scan" "bandit -r src -f json -o bandit-report.json" "Bandit passed" "Bandit failed" + run_check "Safety vulnerability check" "safety check --json --output safety-report.json" "Safety passed" "Safety failed" + run_check "Secrets detection" "detect-secrets scan --baseline .secrets.baseline" "Secrets check passed" "Secrets check failed" +} + +# Documentation checks +run_documentation_checks() { + print_header "SECTION" "Documentation Checks" + + run_check "Docstring style" "pydocstyle --convention=google src/" "Pydocstyle passed" "Pydocstyle failed" + run_check "Coverage check" "pytest --cov=src --cov-report=term-missing --cov-fail-under=80" "Coverage check passed" "Coverage check failed" +} + +# Performance checks +run_performance_checks() { + print_header "SECTION" "Performance Checks" + + run_check "Complexity check" "radon cc src/ -nb -a" "Complexity check passed" "Complexity check failed" + run_check "Maintainability index" "radon cc src/ -nb" "Maintainability check passed" "Maintainability check failed" +} + +# Dependency checks +run_dependency_checks() { + print_header "SECTION" "Dependency Checks" + + run_check "Dependency vulnerability" "pip-audit" "Dependency audit passed" "Dependency audit failed" + run_check "Outdated dependencies" "pip list --outdated --format=json --timeout 60" "Dependency check passed" "Dependency check failed" +} + +# Code quality metrics +run_quality_metrics() { + print_header "SECTION" "Quality Metrics" + + # Check file sizes + local large_files=$(find src/ -name "*.py" -size +50k) + if [ -n "$large_files" ]; then + print_warning "Large files found: $large_files" + add_check_result "File sizes" "warning" "Large files detected" "$large_files" + TOTAL=$((TOTAL + 1)) + FAILED=$((FAILED + 1)) + else + PASSED=$((PASSED + 1)) + print_success "All files are under size limit" + add_check_result "File sizes" "passed" "All files under size limit" "" + fi + + # Check cyclomatic complexity + local complex_files=$(radon cc src/ -nb -a | grep -v "F" | awk '$2 > 10 {print $1}') + if [ -n "$complex_files" ]; then + print_warning "Complex files found: $complex_files" + add_check_result "Complexity" "warning" "Complex files detected" "$complex_files" + TOTAL=$((TOTAL + 1)) + FAILED=$((FAILED + 1)) + else + PASSED=$((PASSED + 1)) + print_success "All files have acceptable complexity" + add_check_result "Complexity" "passed" "All files have acceptable complexity" "" + fi +} + +# Cleanup function +cleanup() { + # Remove temporary files + rm -f temp.json bandit-report.json safety-report.json +} + +# Main execution +main() { + # Set up cleanup trap + trap cleanup EXIT + + # Initialize results + init_results + + echo "Starting Quality Gate checks..." + echo "==================================" + + # Run all checks + run_linting_checks + run_security_checks + run_documentation_checks + run_performance_checks + run_dependency_checks + run_quality_metrics + + # Summary + echo "" + echo "==================================" + echo "Quality Gate Summary" + echo "==================================" + echo "Total checks: $TOTAL" + echo "Passed: $PASSED" + echo "Failed: $FAILED" + + if [ $FAILED -eq 0 ]; then + echo "" + print_success "All checks passed! Quality gate passed." + exit 0 + else + echo "" + print_error "$FAILED checks failed. Quality gate failed." + exit 1 + fi +} + +# Check if we're in the right directory +if [ ! -f "pyproject.toml" ] || [ ! -d "src" ]; then + print_error "This script must be run from the config-kit root directory" + exit 1 +fi + +# Check for required commands +if ! command_exists ruff; then + print_error "ruff is required but not installed" + exit 1 +fi + +if ! command_exists black; then + print_error "black is required but not installed" + exit 1 +fi + +if ! command_exists isort; then + print_error "isort is required but not installed" + exit 1 +fi + +if ! command_exists mypy; then + print_error "mypy is required but not installed" + exit 1 +fi + +# Run main function +main "$@" diff --git a/packages/phenokit-config-kit/linting/git-hooks/commit-msg b/packages/phenokit-config-kit/linting/git-hooks/commit-msg new file mode 100755 index 0000000..c081969 --- /dev/null +++ b/packages/phenokit-config-kit/linting/git-hooks/commit-msg @@ -0,0 +1,129 @@ +#!/bin/bash + +# Exit immediately if a command exits with a non-zero status +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +# Get the commit message file +COMMIT_MSG_FILE=$1 + +# Check if commit message file exists +if [ ! -f "$COMMIT_MSG_FILE" ]; then + print_error "Commit message file not found: $COMMIT_MSG_FILE" + exit 1 +fi + +# Read the commit message +COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") + +# Check for common patterns that should not be in commit messages +check_commit_message() { + local msg="$1" + + # Check for empty message + if [ -z "$msg" ] || [ -z "$(echo "$msg" | xargs)" ]; then + print_error "Commit message cannot be empty" + return 1 + fi + + # Check for WIP (work in progress) commits + if echo "$msg" | grep -qi "^wip\|^work in progress\|^draft\|^update"; then + print_error "Commit message appears to be a work in progress" + return 1 + fi + + # Check for very short messages (less than 5 characters) + if [ ${#msg} -lt 5 ]; then + print_error "Commit message is too short (minimum 5 characters)" + return 1 + fi + + # Check for all caps messages (might be a typo) + if echo "$msg" | grep -q "^[A-Z ]*$" && [ ${#msg} -gt 20 ]; then + print_error "Commit message appears to be in all caps (possible typo)" + return 1 + fi + + # Check for common forbidden words + if echo "$msg" | grep -qi "\btest\b\|fixme\b\|hack\b\|temporary\b\|temp\b"; then + print_error "Commit message contains discouraged words" + return 1 + fi + + # Check for proper capitalization (first letter should be uppercase) + if ! echo "$msg" | grep -q "^[A-Z]"; then + print_error "Commit message should start with a capital letter" + return 1 + fi + + # Check for proper punctuation (should end with period, question mark, or exclamation) + if ! echo "$msg" | grep -q "[.!?]$"; then + print_error "Commit message should end with proper punctuation" + return 1 + fi + + return 0 +} + +# Check if commit message follows conventional commits format +check_conventional_commits() { + local msg="$1" + + # Check for conventional commits format + if echo "$msg" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert|wip)(\([^)]+\))?: "; then + return 0 + fi + + # If not conventional commits, check for minimum quality + if check_commit_message "$msg"; then + return 0 + fi + + return 1 +} + +# Main execution +main() { + print_success "Validating commit message..." + + # Check commit message + if check_conventional_commits "$COMMIT_MSG"; then + print_success "Commit message is valid" + exit 0 + else + print_error "Invalid commit message format" + echo "" + echo "Please use conventional commits format:" + echo " feat: add new feature" + echo " fix: resolve issue" + echo " docs: update documentation" + echo " style: code formatting" + echo " refactor: code refactoring" + echo " test: add tests" + echo " chore: maintenance tasks" + echo "" + echo "Or at minimum:" + echo " - Start with a capital letter" + echo " - End with proper punctuation" + echo " - Be descriptive and meaningful" + echo "" + exit 1 + fi +} + +# Run main function +main "$@" diff --git a/packages/phenokit-config-kit/linting/git-hooks/pre-commit b/packages/phenokit-config-kit/linting/git-hooks/pre-commit new file mode 100755 index 0000000..0db1780 --- /dev/null +++ b/packages/phenokit-config-kit/linting/git-hooks/pre-commit @@ -0,0 +1,149 @@ +#!/bin/bash + +# Exit immediately if a command exits with a non-zero status +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Get staged Python files +get_staged_py_files() { + git diff --cached --name-only --diff-filter=AM -- "*.py" "*.pyi" +} + +# Get staged files for other linters +get_staged_files() { + git diff --cached --name-only --diff-filter=AM +} + +# Lint staged Python files +lint_python_files() { + local files="$1" + if [ -z "$files" ]; then + print_status "No Python files to lint" + return 0 + fi + + print_status "Linting Python files..." + + # Run Ruff + if command_exists ruff; then + print_status "Running Ruff..." + echo "$files" | xargs ruff check --fix + echo "$files" | xargs ruff format --check + else + print_warning "Ruff not found, skipping..." + fi + + # Run Black + if command_exists black; then + print_status "Running Black..." + echo "$files" | xargs black --check + else + print_warning "Black not found, skipping..." + fi + + # Run isort + if command_exists isort; then + print_status "Running isort..." + echo "$files" | xargs isort --check-only + else + print_warning "isort not found, skipping..." + fi + + # Run mypy + if command_exists mypy; then + print_status "Running mypy..." + echo "$files" | xargs mypy + else + print_warning "mypy not found, skipping..." + fi +} + +# Lint all staged files +lint_all_files() { + local files="$1" + if [ -z "$files" ]; then + print_status "No files to lint" + return 0 + fi + + # Run bandit (security linting) + if command_exists bandit; then + print_status "Running bandit..." + echo "$files" | xargs bandit -r -f json + else + print_warning "bandit not found, skipping..." + fi + + # Run yamllint + if command_exists yamllint; then + print_status "Running yamllint..." + echo "$files" | xargs yamllint + else + print_warning "yamllint not found, skipping..." + fi + + # Run markdownlint + if command_exists markdownlint; then + print_status "Running markdownlint..." + echo "$files" | xargs markdownlint + else + print_warning "markdownlint not found, skipping..." + fi + + # Run shellcheck + if command_exists shellcheck; then + print_status "Running shellcheck..." + echo "$files" | xargs shellcheck + else + print_warning "shellcheck not found, skipping..." + fi +} + +# Main execution +main() { + print_status "Starting pre-commit linting..." + + # Get staged files + staged_py_files=$(get_staged_py_files) + staged_files=$(get_staged_files) + + # If no files are staged, exit + if [ -z "$staged_files" ]; then + print_status "No files staged, skipping pre-commit hooks" + exit 0 + fi + + # Lint Python files + lint_python_files "$staged_py_files" + + # Lint all files (other linters) + lint_all_files "$staged_files" + + print_status "Pre-commit linting completed successfully!" +} + +# Run main function +main "$@" diff --git a/packages/phenokit-config-kit/linting/linters.toml b/packages/phenokit-config-kit/linting/linters.toml new file mode 100644 index 0000000..19a0ef5 --- /dev/null +++ b/packages/phenokit-config-kit/linting/linters.toml @@ -0,0 +1,114 @@ +[linters] +enabled = [ + "ruff", + "black", + "isort", + "mypy", + "bandit", + "safety", + "yamllint", + "markdownlint", + "pydocstyle", + "docformatter", + "interrogate", + "vulture", + "radon", + "pyupgrade" +] + +[linters.ruff] +command = "ruff check" +fix_command = "ruff check --fix" +output_format = "github" +target_version = "py311" +line_length = 88 + +[linters.black] +command = "black --check" +fix_command = "black" +target_version = "py311" +line_length = 88 + +[linters.isort] +command = "isort --check-only" +fix_command = "isort" +profile = "black" +multi_line_output = 3 +line_length = 88 + +[linters.mypy] +command = "mypy" +python_version = "3.11" +strict = true +ignore_missing_imports = true + +[linters.bandit] +command = "bandit -r src -f json" +exclude_dirs = ["tests", "test"] +output_format = "json" + +[linters.safety] +command = "safety check --json --file safety_report.json" +check_authored = true +check_only = false + +[linters.yamllint] +command = "yamllint" +extends = "default" +rules.line_length.max = 88 +rules.indentation.indent_sequences = true + +[linters.markdownlint] +command = "markdownlint" +config = ".markdownlint.json" +extensions = ["md", "markdown"] + +[linters.pydocstyle] +command = "pydocstyle" +convention = "google" +add_ignore = ["D100", "D104", "D107"] +match = "(?!test_).*\\.py" + +[linters.docformatter] +command = "docformatter --check" +wrap = 88 +blank_after_summary = false + +[linters.interrogate] +command = "interrogate" +fail_under = 80 +ignore_init_method = true +ignore_init_module = false +ignore_magic = false +ignore_nested_functions = false +ignore_private = true +ignore_property_decorators = true +ignore_module = false + +[linters.vulture] +command = "vulture" +min_confidence = 80 +paths = ["src"] +exclude = ["tests/*"] + +[linters.radon] +command = "radon cc -nb -a" +max_complexity = 10 +min_a = 0 +show_closures = false + +[linters.pyupgrade] +command = "pyupgrade --py37-plus --keep-runtime-typing" + +[workflow] +staged_files_command = "git diff --cached --name-only --diff-filter=AM" +staged_py_files_command = 'git diff --cached --name-only --diff-filter=AM -- "*.py" "*.pyi"' +parallel_linters = true +max_parallel_jobs = 4 +timeout = 300 + +[reporting] +format = "github" +fail_on_error = true +show_diff = true +verbose = false diff --git a/packages/phenokit-config-kit/linting/pre-commit-hooks.yaml b/packages/phenokit-config-kit/linting/pre-commit-hooks.yaml new file mode 100644 index 0000000..0dfb78f --- /dev/null +++ b/packages/phenokit-config-kit/linting/pre-commit-hooks.yaml @@ -0,0 +1,134 @@ +# Pre-commit hooks for config-kit +# These hooks run automatically when committing + +repos: + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + language_version: python3.11 + args: [--line-length=88] + files: \.(py|pyi)$ + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: [--profile=black, --line-length=88] + files: \.(py|pyi)$ + + - repo: https://github.com/pycqa/ruff-pre-commit + rev: v0.6.4 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + files: \.(py|pyi)$ + - id: ruff-format + args: [--check] + files: \.(py|pyi)$ + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.13.0 + hooks: + - id: mypy + additional_dependencies: [types-all] + args: [--ignore-missing-imports, --show-error-codes] + files: \.(py|pyi)$ + + - repo: https://github.com/PyCQA/bandit + rev: 1.7.9 + hooks: + - id: bandit + args: [-r, src, -f, json] + files: \.(py|pyi)$ + exclude: ^tests/ + + - repo: https://github.com/PyCQA/yamllint + rev: v1.35.1 + hooks: + - id: yamllint + args: [-d, relaxed] + files: \.(yaml|yml)$ + + - repo: https://github.com/PyCQA/markdownlint-cli + rev: v0.41.0 + hooks: + - id: markdownlint + args: [-c, .markdownlint.json] + files: \.(md|markdown)$ + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + args: [-e, SC1091] + files: \.(sh|bash|zsh)$ + + - repo: https://github.com/pycqa/pydocstyle + rev: 6.3.0 + hooks: + - id: pydocstyle + args: [--convention=google] + files: \.(py|pyi)$ + exclude: ^tests/ + + - repo: https://github.com/pycqa/docformatter + rev: v1.7.5 + hooks: + - id: docformatter + args: [--in-place, --wrap-summaries=88, --wrap-descriptions=88] + files: \.(py|pyi)$ + exclude: ^tests/ + + - repo: https://github.com/econchick/interrogate + rev: 1.5.0 + hooks: + - id: interrogate + args: [--verbose, --ignore-init-method, --ignore-init-module, --fail-under=80] + files: \.(py|pyi)$ + exclude: ^tests/ + + - repo: https://github.com/jakewharton/pygradle-precommit + rev: 1.0.2 + hooks: + - id: pyupgrade + args: [--py37-plus, --keep-runtime-typing] + files: \.(py|pyi)$ + + - repo: local + hooks: + - id: safety-check + name: Safety check + entry: safety + language: system + args: [check, --json, --file, safety_report.json] + pass_filenames: false + always_run: true + + - id: shellcheck-fix + name: Shellcheck (fix) + entry: shellcheck + language: system + args: [-e, SC1091, -f, gcc] + files: \.(sh|bash|zsh)$ + + - id: yamllint-fix + name: YAMLLint (fix) + entry: yamllint + language: system + args: [-d, relaxed, -f] + files: \.(yaml|yml)$ + + - id: pre-commit-hooks + name: Pre-commit hooks + entry: bash + language: system + args: [linting/git-hooks/pre-commit] + files: \.(py|pyi|yaml|yml|md|sh)$ + + - id: commit-message-check + name: Commit message check + entry: bash + language: system + args: [linting/git-hooks/commit-msg] + files: ^COMMIT_EDITMSG$^ diff --git a/packages/phenokit-config-kit/linting/pyproject.toml b/packages/phenokit-config-kit/linting/pyproject.toml new file mode 100644 index 0000000..de171fe --- /dev/null +++ b/packages/phenokit-config-kit/linting/pyproject.toml @@ -0,0 +1,195 @@ +[tool.ruff] +line-length = 88 +target-version = "py311" +fix = true + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG001", # unused arguments in functions + "ARG002", # unused arguments in methods + "ARG005", # unused arguments in lambda functions +] + +ignore = [ + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults + "W191", # indentation contains tabs + "B904", # Allow raising exceptions without from e, for HTTPException +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["ARG001", "ARG002"] +"__init__.py" = ["F401"] + +[tool.ruff.lint.pyupgrade] +keep-runtime-typing = true + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["config_kit"] +force_single_line = false +combine_as_imports = true +include_trailing_comma = true + +[tool.black] +line-length = 88 +target-version = ['py311'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true +plugins = ["pydantic.mypy"] + +[[tool.mypy.overrides]] +module = [ + "pytest.*", + "pytest_asyncio.*", + "bandit.*", + "safety.*", + "shellcheck.*", + "yamllint.*", + "markdownlint.*", + "lychee.*", + "pre_commit.*", + "vulture.*", + "radon.*", + "pyupgrade.*", + "docformatter.*", + "pydocstyle.*", + "interrogate.*", + "detect_secrets.*", + "semgrep.*", + "trivy.*", + "mkdocs.*", + "allure.*", + "locust.*", + "memory_profiler.*", + "playwright.*", + "lychee.*", +] +ignore_missing_imports = true + +[tool.bandit] +exclude_dirs = ["tests", "test"] +skips = ["B101"] # assert_used + +[tool.safety] +check-authored = true +check-only = false +json-report = true +output = "safety_report.json" + +[tool.yamllint] +extends = "default" + +[tool.yamllint.rules] +line-length = { max = 88, level = "warning" } +indentation = { indent-sequences = true, check-multi-line-strings = false } + +[tool.markdownlint] +MD001 = false +MD013 = {line_length = 88} +MD024 = {allow_different_nesting = true} +MD033 = false +MD034 = false +MD036 = false +MD037 = false +MD038 = false +MD039 = false +MD040 = false +MD041 = false +MD042 = false +MD043 = false +MD044 = false +MD045 = false +MD046 = false +MD047 = false +MD048 = {level = "warning"} +MD049 = {level = "warning"} +MD050 = {level = "warning"} +MD051 = {level = "warning"} +MD052 = {level = "warning"} +MD053 = {level = "warning"} +MD054 = {level = "warning"} + +[tool.pydocstyle] +convention = "google" +add-ignore = ["D100", "D104", "D107"] +match = "(?!test_).*\\.py" +match-dir = "(?!tests).*" + +[tool.interrogate] +ignore-init-method = true +ignore-init-module = false +ignore-magic = false +ignore-nested-functions = false +ignore-private = true +ignore-property-decorators = true +ignore-module = false +fail-under = 80 +exclude = ["tests", "test"] +verbose = 1 +quiet = false +whitelist-names = [] +whitelist-regex = [] +color = true +omit-covered-files = false + +[tool.pyupgrade] +keep-runtime-typing = true + +[tool.docformatter] +wrap-summaries = 88 +wrap-descriptions = 88 +blank-after-summary = false +pre-summary-newline = false +make-summary-multi-line = false +close-quotes-on-newline = false + +[tool.radon] +cc-max = 10 +max-complexity = 10 +min-a = 0 +show-closures = false +exclude = ["tests/*"] + +[tool.vulture] +min_confidence = 80 +verbose = false +make_whitelist = false +paths = ["src"] +exclude = ["tests/*"] diff --git a/packages/phenokit-config-kit/pre-commit/basic.yaml b/packages/phenokit-config-kit/pre-commit/basic.yaml new file mode 100644 index 0000000..eddb63d --- /dev/null +++ b/packages/phenokit-config-kit/pre-commit/basic.yaml @@ -0,0 +1,32 @@ +# Basic Pre-commit Configuration +# Minimal configuration for simple projects + +repos: + # Pre-commit hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-added-large-files + args: ['--maxkb=51200'] # 50MB limit + - id: check-merge-conflict + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-json + - id: check-toml + + # Ruff linting and formatting (replaces black + isort + flake8) + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.3 + hooks: + - id: ruff + args: ['--fix', '--exit-non-zero-on-fix'] + - id: ruff-format + + # MyPy type checking + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + additional_dependencies: [types-all] + args: ['--ignore-missing-imports'] diff --git a/packages/phenokit-config-kit/pre-commit/comprehensive.yaml b/packages/phenokit-config-kit/pre-commit/comprehensive.yaml new file mode 100644 index 0000000..9420ef7 --- /dev/null +++ b/packages/phenokit-config-kit/pre-commit/comprehensive.yaml @@ -0,0 +1,292 @@ +# Comprehensive Pre-commit Configuration +# Restored from atoms_mcp-old massive simplification +# This is the FULL configuration that was lost + +repos: + # Pre-commit hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-added-large-files + args: ['--maxkb=51200'] # 50MB limit + - id: check-merge-conflict + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-xml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-docstring-first + - id: check-ast + - id: check-merge-conflict + - id: debug-statements + - id: check-json + - id: check-yaml + - id: check-toml + - id: check-xml + - id: check-merge-conflict + - id: debug-statements + - id: name-tests-test + - id: requirements-txt-fixer + - id: fix-byte-order-marker + - id: check-case-conflict + - id: check-merge-conflict + - id: check-symlinks + - id: check-added-large-files + - id: check-merge-conflict + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-xml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-docstring-first + - id: check-ast + - id: check-merge-conflict + - id: debug-statements + - id: check-json + - id: check-yaml + - id: check-toml + - id: check-xml + - id: check-merge-conflict + - id: debug-statements + - id: name-tests-test + - id: requirements-txt-fixer + - id: fix-byte-order-marker + - id: check-case-conflict + - id: check-merge-conflict + - id: check-symlinks + + # Black code formatting + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + language_version: python3 + args: ['--line-length=100', '--target-version=py311'] + + # isort import sorting + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: ['--profile', 'black', '--line-length=100'] + + # MyPy type checking + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + additional_dependencies: [types-all] + args: ['--strict', '--ignore-missing-imports'] + + # Ruff linting and formatting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.3 + hooks: + - id: ruff + args: ['--fix', '--exit-non-zero-on-fix'] + - id: ruff-format + + # Bandit security scanning + - repo: https://github.com/pycqa/bandit + rev: 1.7.8 + hooks: + - id: bandit + args: ['-r', '.', '-f', 'json', '-o', 'bandit-report.json'] + exclude: 'tests/' + + # Prospector comprehensive analysis + - repo: https://github.com/PyCQA/prospector + rev: 1.10.3 + hooks: + - id: prospector + args: ['--strictness=veryhigh', '--doc-warnings'] + + # Radon complexity analysis + - repo: https://github.com/rubik/radon + rev: 6.0.1 + hooks: + - id: radon-cc + args: ['--min=B', '--show-complexity'] + - id: radon-mi + args: ['--min=B'] + + # Vulture dead code detection + - repo: https://github.com/jendrikseipp/vulture + rev: v2.11 + hooks: + - id: vulture + args: ['--min-confidence=80'] + + # Pyupgrade Python syntax upgrader + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.2 + hooks: + - id: pyupgrade + args: ['--py311-plus'] + + # Docformatter docstring formatting + - repo: https://github.com/pycqa/docformatter + rev: v1.7.5 + hooks: + - id: docformatter + args: ['--wrap-summaries=100', '--wrap-descriptions=100'] + + # Pydocstyle docstring style checking + - repo: https://github.com/pycqa/pydocstyle + rev: 6.3.0 + hooks: + - id: pydocstyle + args: ['--convention=google'] + + # Interrogate documentation coverage + - repo: https://github.com/econchick/interrogate + rev: 1.5.0 + hooks: + - id: interrogate + args: ['--fail-under=80', '--ignore-init-module', '--ignore-init-method'] + + # Pip-audit dependency security audit + - repo: https://github.com/pypa/pip-audit + rev: v2.7.0 + hooks: + - id: pip-audit + args: ['--desc', '--format=json', '--output=pip-audit-report.json'] + + # Safety dependency vulnerability scanning + - repo: https://github.com/Lucas-C/pre-commit-hooks-safety + rev: v1.3.2 + hooks: + - id: python-safety-dependencies-check + + # Markdown linting + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.38.0 + hooks: + - id: markdownlint + args: ['--fix'] + + # YAML linting + - repo: https://github.com/adrienverge/yamllint + rev: v1.35.1 + hooks: + - id: yamllint + args: ['-d', 'relaxed'] + + # Shell script linting + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + + # Dockerfile linting + - repo: https://github.com/hadolint/hadolint + rev: v2.12.0 + hooks: + - id: hadolint-docker + args: ['--ignore', 'DL3008', '--ignore', 'DL3009'] + + # File cleanup hooks + - repo: local + hooks: + - id: remove-tabs + name: Remove tabs + entry: sed + args: ['-i', 's/\t/ /g'] + language: system + files: \.py$ + + - id: remove-trailing-spaces + name: Remove trailing spaces + entry: sed + args: ['-i', 's/[[:space:]]*$//'] + language: system + files: \.(py|md|yaml|yml|json|toml)$ + + - id: fix-line-endings + name: Fix line endings + entry: dos2unix + language: system + files: \.(py|md|yaml|yml|json|toml|sh)$ + + - id: remove-empty-lines + name: Remove empty lines at end of file + entry: sed + args: ['-i', '-e', ':a', '-e', '/^\s*$/N;ba', '-e', 's/\n*$//'] + language: system + files: \.(py|md|yaml|yml|json|toml)$ + + # Architecture fitness hooks (custom) + - repo: local + hooks: + - id: architecture-fitness + name: Architecture fitness check + entry: python + args: ['scripts/architecture_fitness_check.py'] + language: system + files: \.py$ + pass_filenames: false + + - id: file-size-check + name: File size check + entry: python + args: ['scripts/file_size_check.py', '--max-lines=500'] + language: system + files: \.py$ + + - id: import-boundary-check + name: Import boundary check + entry: python + args: ['scripts/import_boundary_check.py'] + language: system + files: \.py$ + + - id: dependency-direction-check + name: Dependency direction check + entry: python + args: ['scripts/dependency_direction_check.py'] + language: system + files: \.py$ + + - id: naming-convention-check + name: Naming convention check + entry: python + args: ['scripts/naming_convention_check.py'] + language: system + files: \.py$ + + - id: cyclomatic-complexity-check + name: Cyclomatic complexity check + entry: python + args: ['scripts/cyclomatic_complexity_check.py', '--max-complexity=10'] + language: system + files: \.py$ + + - id: test-coverage-check + name: Test coverage check + entry: python + args: ['scripts/test_coverage_check.py', '--min-coverage=80'] + language: system + files: \.py$ + + - id: config-validation-check + name: Config validation check + entry: python + args: ['scripts/config_validation_check.py'] + language: system + files: \.(yaml|yml|json|toml)$ + + - id: dependency-security-audit + name: Dependency security audit + entry: python + args: ['scripts/dependency_security_audit.py'] + language: system + files: requirements.*\.txt$ + + - id: comprehensive-report + name: Comprehensive quality report + entry: python + args: ['scripts/comprehensive_quality_report.py'] + language: system + pass_filenames: false diff --git a/packages/phenokit-config-kit/pre-commit/security.yaml b/packages/phenokit-config-kit/pre-commit/security.yaml new file mode 100644 index 0000000..86a8813 --- /dev/null +++ b/packages/phenokit-config-kit/pre-commit/security.yaml @@ -0,0 +1,114 @@ +# Security-focused Pre-commit Configuration +# Enhanced security scanning and validation + +repos: + # Pre-commit hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-added-large-files + args: ['--maxkb=51200'] # 50MB limit + - id: check-merge-conflict + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-json + - id: check-toml + + # Ruff linting and formatting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.3 + hooks: + - id: ruff + args: ['--fix', '--exit-non-zero-on-fix'] + - id: ruff-format + + # MyPy type checking + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + additional_dependencies: [types-all] + args: ['--strict', '--ignore-missing-imports'] + + # Bandit security scanning + - repo: https://github.com/pycqa/bandit + rev: 1.7.8 + hooks: + - id: bandit + args: ['-r', '.', '-f', 'json', '-o', 'bandit-report.json'] + exclude: 'tests/' + + # Safety dependency vulnerability scanning + - repo: https://github.com/Lucas-C/pre-commit-hooks-safety + rev: v1.3.2 + hooks: + - id: python-safety-dependencies-check + + # Pip-audit dependency security audit + - repo: https://github.com/pypa/pip-audit + rev: v2.7.0 + hooks: + - id: pip-audit + args: ['--desc', '--format=json', '--output=pip-audit-report.json'] + + # Detect secrets + - repo: https://github.com/Yelp/detect-secrets + rev: v1.4.0 + hooks: + - id: detect-secrets + args: ['--baseline', '.secrets.baseline'] + + # Semgrep SAST + - repo: https://github.com/returntocorp/semgrep + rev: v1.62.0 + hooks: + - id: semgrep + args: ['--config=auto', '--severity=ERROR', '--severity=WARNING'] + + # Trivy container vulnerability scanner + - repo: https://github.com/aquasecurity/trivy + rev: v0.45.0 + hooks: + - id: trivy + args: ['fs', '--security-checks', 'vuln,secret', '--format', 'json', '--output', 'trivy-report.json'] + + # Presidio PII detection + - repo: local + hooks: + - id: presidio-analyze + name: Presidio PII analysis + entry: python + args: ['scripts/presidio_analyze.py'] + language: system + files: \.(py|md|txt)$ + + # Security headers check + - repo: local + hooks: + - id: security-headers-check + name: Security headers check + entry: python + args: ['scripts/security_headers_check.py'] + language: system + files: \.(py|yaml|yml)$ + + # Dependency license check + - repo: local + hooks: + - id: license-check + name: License compliance check + entry: python + args: ['scripts/license_check.py'] + language: system + files: requirements.*\.txt$ + + # SBOM generation + - repo: local + hooks: + - id: sbom-generation + name: SBOM generation + entry: python + args: ['scripts/sbom_generation.py'] + language: system + files: requirements.*\.txt$ diff --git a/packages/phenokit-config-kit/pyproject.toml b/packages/phenokit-config-kit/pyproject.toml new file mode 100644 index 0000000..0c7b4b2 --- /dev/null +++ b/packages/phenokit-config-kit/pyproject.toml @@ -0,0 +1,155 @@ +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "phenokit-config-kit" +version = "0.1.0" +description = "Centralized configuration management for Python projects - pytest plugins, fixtures, linting configs, and pre-commit hooks" +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "PhenoKit Team" }] +keywords = ["pytest", "configuration", "linting", "pre-commit", "testing", "fixtures"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Software Development :: Testing", +] +requires-python = ">=3.11" + +[project.optional-dependencies] +# Core dependencies (required for basic usage) +core = [ + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "pyyaml>=6.0", + "toml>=0.10.0", + "click>=8.1.0", + "rich>=13.0.0", +] + +# Pre-commit dependencies +precommit = [ + "pre-commit>=3.0.0", +] + +# Testing dependencies - includes pytest plugins +testing = [ + "pytest>=7.4.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.1.0", + "pytest-mock>=3.11.0", + "pytest-xdist>=3.3.0", + "coverage>=7.4.0", + "psutil>=5.9.0", +] + +# Linting dependencies +linting = [ + "ruff>=0.1.0", + "black>=23.0.0", + "isort>=5.12.0", + "mypy>=1.5.0", + "bandit>=1.7.5", + "prospector>=1.10.0", + "yamllint>=1.35.0", + "markdownlint-cli>=0.38.0", +] + +# All dependencies +all = [ + "phenokit-config-kit[core,precommit,testing,linting]" +] + +[project.entry-points.pytest11] +config_kit_architecture = "config_kit.pytest.plugins.architecture" +config_kit_performance = "config_kit.pytest.plugins.performance" +config_kit_security = "config_kit.pytest.plugins.security" + +[project.urls] +Homepage = "https://github.com/KooshaPari/phenotype-sdk/tree/main/lang/python/packages/phenokit-config-kit" +Documentation = "https://github.com/KooshaPari/phenotype-sdk/blob/main/lang/python/packages/phenokit-config-kit/README.md" +Repository = "https://github.com/KooshaPari/phenotype-sdk" + +[tool.hatch.build.targets.wheel] +packages = ["config_kit"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["config_kit*"] + +[tool.setuptools.package-data] +config_kit = [ + "pre-commit/*.yaml", + "pytest/*.ini", + "pytest/plugins/*.py", + "pytest/fixtures/*.py", + "pytest/data/*.py", + "linting/*", + "linting/ci-cd/*", + "linting/git-hooks/*", +] + +[tool.ruff] +line-length = 88 +target-version = "py311" +fix = true + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["ARG001", "ARG002"] +"__init__.py" = ["F401"] + +[tool.black] +line-length = 88 +target-version = ['py311'] +include = '\.pyi?$' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["config_kit"] +combine_as_imports = true +include_trailing_comma = true + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +ignore_missing_imports = true +plugins = ["pydantic.mypy"] + +[tool.pytest.ini_options] +minversion = "7.4" +testpaths = ["tests"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +markers = [ + "unit: Unit tests (fast, isolated)", + "integration: Integration tests", + "performance: Performance tests", + "security: Security tests", +] diff --git a/packages/phenokit-config-kit/pytest/README.md b/packages/phenokit-config-kit/pytest/README.md new file mode 100644 index 0000000..5e2aa1a --- /dev/null +++ b/packages/phenokit-config-kit/pytest/README.md @@ -0,0 +1,605 @@ +# Pytest Configuration Kit + +This package provides comprehensive pytest configurations, plugins, fixtures, and test data management utilities for Python projects. + +## Features + +- **Multiple Configuration Profiles**: Basic, comprehensive, CI, parallel, performance, and security configurations +- **Architecture Testing**: File size validation, import boundary enforcement, dependency direction validation +- **Performance Testing**: Benchmark testing, memory profiling, performance regression detection +- **Security Testing**: Vulnerability scanning, authentication testing, authorization testing +- **Rich Fixtures**: Common, performance, and security fixtures for testing +- **Test Data Management**: Factory for generating test data across different entity types + +## Quick Start + +### Basic Usage + +```python +# Use basic configuration +pytest --ini=pytest/basic.ini + +# Use comprehensive configuration +pytest --ini=pytest/comprehensive.ini + +# Use CI configuration +pytest --ini=pytest/ci.ini +``` + +### Advanced Usage + +```python +# Use parallel execution +pytest --ini=pytest/parallel.ini -m "fast and not serial" -n auto + +# Use performance testing +pytest --ini=pytest/performance.ini -m performance + +# Use security testing +pytest --ini=pytest/security.ini -m security +``` + +## Configuration Files + +### Basic Configuration (`basic.ini`) + +Simple configuration for basic projects: + +```ini +[pytest] +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +addopts = + -v + --tb=short + --strict-markers + --maxfail=5 + --durations=10 + +markers = + unit: Unit tests (fast, isolated, no I/O) + integration: Integration tests (slower, may use I/O) + slow: Slow running tests (> 5 seconds) + fast: Fast running tests (< 1 second) +``` + +### Comprehensive Configuration (`comprehensive.ini`) + +Enterprise-grade configuration with parallel execution, coverage, and extensive markers: + +```ini +[pytest] +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +addopts = + -v + --tb=short + --strict-markers + --strict-config + --maxfail=10 + --durations=10 + --durations-min=2.0 + --cache-clear + # Parallel execution + -n auto + --dist=loadscope + # Coverage analysis + --cov=src + --cov-report=term-missing:skip-covered + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml + --cov-fail-under=80 + --cov-branch + # JUnit XML for CI integration + --junitxml=reports/pytest-junit.xml +``` + +### CI Configuration (`ci.ini`) + +Optimized for CI/CD environments with parallel execution and comprehensive reporting: + +```ini +[pytest] +# CI/CD-optimized options +addopts = + -ra + --strict-markers + --strict-config + --tb=short + --maxfail=10 + --durations=10 + --durations-min=1.0 + --cache-clear + # Parallel execution optimized for CI + -n auto + --dist=worksteal + # Include integration tests but exclude external service dependencies + -m "not external and not nats and not redis and not temporal" + # Coverage reporting + --cov=src + --cov-report=term-missing:skip-covered + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml + --cov-fail-under=85 + # JUnit XML for CI integration + --junitxml=reports/pytest-junit.xml +``` + +### Parallel Configuration (`parallel.ini`) + +Optimized for parallel test execution: + +```ini +[pytest] +# Fast unit tests only (no external dependencies) +addopts = + -v + --strict-markers + --tb=short + --disable-warnings + --strict-config + --maxfail=3 + --durations=5 + --durations-min=1.0 + -n auto + --dist=loadscope +``` + +### Performance Configuration (`performance.ini`) + +Specialized for performance testing: + +```ini +[pytest] +# Performance testing options +addopts = + -v + --tb=short + --strict-markers + --maxfail=5 + --durations=20 + --durations-min=0.1 + # Performance-specific options + --benchmark-only + --benchmark-sort=mean + --benchmark-skip + --benchmark-save=performance_results + --benchmark-save-data + # Memory profiling + --profile + --profile-svg + # Coverage for performance tests + --cov=src + --cov-report=term-missing + --cov-report=html:htmlcov +``` + +### Security Configuration (`security.ini`) + +Specialized for security testing: + +```ini +[pytest] +# Security testing options +addopts = + -v + --tb=short + --strict-markers + --maxfail=5 + --durations=10 + --durations-min=1.0 + # Security-specific options + --bandit + --bandit-config=.bandit + --safety + --safety-json + # Coverage for security tests + --cov=src + --cov-report=term-missing + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml +``` + +## Plugins + +### Architecture Plugin + +Provides architecture fitness tests: + +```python +# Test import boundaries +def test_import_boundaries(): + """Test that import boundaries are respected.""" + pass + +# Test dependency direction +def test_dependency_direction(): + """Test that dependency direction is respected.""" + pass + +# Test file sizes +def test_file_sizes(): + """Test that file sizes are within limits.""" + pass + +# Test cyclomatic complexity +def test_cyclomatic_complexity(): + """Test that cyclomatic complexity is within limits.""" + pass +``` + +### Performance Plugin + +Provides performance testing capabilities: + +```python +# Monitor performance during test execution +@pytest.fixture +def performance_monitor(): + """Monitor performance during test execution.""" + pass + +# Benchmark testing +@pytest.mark.performance +def test_benchmark_performance(): + """Test that benchmark performance meets thresholds.""" + pass + +# Memory usage testing +@pytest.mark.memory +def test_memory_usage(): + """Test that memory usage is within thresholds.""" + pass +``` + +### Security Plugin + +Provides security testing capabilities: + +```python +# Vulnerability scanning +@pytest.mark.security +def test_vulnerability_scan(): + """Scan code for common vulnerabilities.""" + pass + +# Forbidden imports check +@pytest.mark.security +def test_forbidden_imports(): + """Check for forbidden imports that could be security risks.""" + pass + +# Hardcoded secrets check +@pytest.mark.security +def test_hardcoded_secrets(): + """Check for hardcoded secrets in the code.""" + pass +``` + +## Fixtures + +### Common Fixtures + +```python +# Temporary directory +@pytest.fixture +def temp_dir(): + """Create a temporary directory for testing.""" + pass + +# Mock client +@pytest.fixture +def mock_client(): + """Create a mock client for testing.""" + pass + +# Test data +@pytest.fixture +def test_data(): + """Provide test data for testing.""" + pass + +# Performance monitor +@pytest.fixture +def performance_monitor(): + """Monitor performance during test execution.""" + pass + +# Security context +@pytest.fixture +def security_context(): + """Provide security context for testing.""" + pass +``` + +### Performance Fixtures + +```python +# Performance monitor +@pytest.fixture +def performance_monitor(): + """Monitor performance during test execution.""" + pass + +# Benchmark data +@pytest.fixture +def benchmark_data(): + """Provide benchmark data for testing.""" + pass + +# Load test data +@pytest.fixture +def load_test_data(): + """Provide load test data for testing.""" + pass + +# Memory profiler +@pytest.fixture +def memory_profiler(): + """Provide memory profiling capabilities.""" + pass +``` + +### Security Fixtures + +```python +# Security context +@pytest.fixture +def security_context(): + """Provide security context for testing.""" + pass + +# Mock auth client +@pytest.fixture +def mock_auth_client(): + """Create a mock authentication client for testing.""" + pass + +# Test credentials +@pytest.fixture +def test_credentials(): + """Provide test credentials for testing.""" + pass + +# Test tokens +@pytest.fixture +def test_tokens(): + """Provide test tokens for testing.""" + pass +``` + +## Test Data Management + +### Test Data Factory + +```python +from config_kit.pytest.data.factory import TestDataFactory + +# Create factory +factory = TestDataFactory(seed=42) + +# Generate user data +user_data = factory.user_data() +# { +# "id": "uuid-123", +# "username": "testuser", +# "email": "test@example.com", +# "full_name": "Test User", +# "roles": ["user"], +# "created_at": datetime.now(), +# "updated_at": datetime.now(), +# "is_active": True, +# } + +# Generate organization data +org_data = factory.organization_data() +# { +# "id": "uuid-456", +# "name": "Test Organization", +# "description": "Test organization description", +# "created_at": datetime.now(), +# "updated_at": datetime.now(), +# "is_active": True, +# } + +# Generate project data +project_data = factory.project_data(organization_id="org-123") +# { +# "id": "uuid-789", +# "name": "Test Project", +# "description": "Test project description", +# "status": "active", +# "organization_id": "org-123", +# "created_at": datetime.now(), +# "updated_at": datetime.now(), +# } + +# Generate related data +users = factory.create_related_data("user", count=5) +organizations = factory.create_related_data("organization", count=3) +``` + +## Usage Examples + +### Basic Testing + +```python +import pytest +from config_kit.pytest.fixtures.common import mock_client, test_data + +def test_basic_functionality(mock_client, test_data): + """Test basic functionality.""" + result = mock_client.call_tool("test_tool", test_data["user"]) + assert result["success"] is True +``` + +### Performance Testing + +```python +import pytest +from config_kit.pytest.fixtures.performance import performance_monitor + +def test_performance(performance_monitor): + """Test performance.""" + with performance_monitor["measure"]("test_operation"): + # Perform operation + result = expensive_operation() + + assert result is not None + assert performance_monitor["total_duration"] < 1.0 +``` + +### Security Testing + +```python +import pytest +from config_kit.pytest.fixtures.security import security_context, test_credentials + +def test_authentication(security_context, test_credentials): + """Test authentication.""" + auth_result = authenticate_user( + test_credentials["username"], + test_credentials["password"] + ) + + assert auth_result["success"] is True + assert auth_result["user_id"] == security_context["user_id"] +``` + +### Architecture Testing + +```python +import pytest +from config_kit.pytest.plugins.architecture import ArchitecturePlugin + +def test_architecture_fitness(): + """Test architecture fitness.""" + # This test is automatically added by the ArchitecturePlugin + pass +``` + +## Command Line Options + +### Architecture Testing + +```bash +# Set maximum file size +pytest --max-file-size=1000 + +# Set maximum complexity +pytest --max-complexity=10 + +# Enable/disable import enforcement +pytest --enforce-imports +pytest --no-enforce-imports + +# Enable/disable dependency enforcement +pytest --enforce-dependencies +pytest --no-enforce-dependencies +``` + +### Performance Testing + +```bash +# Set benchmark threshold +pytest --benchmark-threshold=1.0 + +# Set memory threshold +pytest --memory-threshold=100 +``` + +## Integration with CI/CD + +### GitHub Actions + +```yaml +name: Tests +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install -e . + pip install pytest pytest-cov + + - name: Run tests + run: | + pytest --ini=pytest/ci.ini + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml +``` + +### Pre-commit Hooks + +```yaml +repos: + - repo: local + hooks: + - id: pytest + name: pytest + entry: pytest + args: [--ini=pytest/basic.ini, -m "not slow"] + language: system + pass_filenames: false + always_run: true +``` + +## Best Practices + +1. **Use appropriate configuration**: Choose the right configuration file for your needs +2. **Mark tests appropriately**: Use markers to categorize tests (unit, integration, performance, security) +3. **Use fixtures**: Leverage the provided fixtures for common testing patterns +4. **Generate test data**: Use the TestDataFactory for consistent test data generation +5. **Monitor performance**: Use performance fixtures to monitor test execution +6. **Test security**: Use security fixtures and plugins to test security aspects +7. **Validate architecture**: Use architecture plugins to validate architectural constraints + +## Troubleshooting + +### Common Issues + +1. **Import errors**: Ensure the config-kit package is installed and in the Python path +2. **Plugin not found**: Check that the plugin is properly registered in pytest configuration +3. **Fixture not found**: Ensure the fixture is imported or the module is in the Python path +4. **Performance issues**: Use parallel execution and appropriate markers for test categorization + +### Debug Mode + +```bash +# Enable debug mode +pytest --ini=pytest/comprehensive.ini -v -s --tb=long + +# Enable logging +pytest --ini=pytest/comprehensive.ini --log-cli-level=DEBUG +``` + +## Contributing + +1. Add new configurations in the `pytest/` directory +2. Add new plugins in the `pytest/plugins/` directory +3. Add new fixtures in the `pytest/fixtures/` directory +4. Add new test data utilities in the `pytest/data/` directory +5. Update documentation and examples + +## License + +MIT License - See LICENSE file for details. \ No newline at end of file diff --git a/packages/phenokit-config-kit/pytest/basic.ini b/packages/phenokit-config-kit/pytest/basic.ini new file mode 100644 index 0000000..dd870b0 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/basic.ini @@ -0,0 +1,27 @@ +[pytest] +# Basic pytest configuration for simple projects +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# Basic options +addopts = + -v + --tb=short + --strict-markers + --maxfail=5 + --durations=10 + +# Basic markers +markers = + unit: Unit tests (fast, isolated, no I/O) + integration: Integration tests (slower, may use I/O) + slow: Slow running tests (> 5 seconds) + fast: Fast running tests (< 1 second) + +# Basic warnings +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning \ No newline at end of file diff --git a/packages/phenokit-config-kit/pytest/ci.ini b/packages/phenokit-config-kit/pytest/ci.ini new file mode 100644 index 0000000..526cb8c --- /dev/null +++ b/packages/phenokit-config-kit/pytest/ci.ini @@ -0,0 +1,88 @@ +[pytest] +# CI/CD Profile - Comprehensive validation with optimized parallel execution +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# CI/CD-optimized options +addopts = + -ra + --strict-markers + --strict-config + --tb=short + --maxfail=10 + --durations=10 + --durations-min=1.0 + --cache-clear + # Parallel execution optimized for CI + -n auto + --dist=worksteal + # Include integration tests but exclude external service dependencies + -m "not external and not nats and not redis and not temporal" + # Coverage reporting + --cov=src + --cov-report=term-missing:skip-covered + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml + --cov-fail-under=85 + # JUnit XML for CI integration + --junitxml=reports/pytest-junit.xml + +# Asyncio configuration +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session + +# CI-specific markers +markers = + fast: Fast unit tests (< 1 second execution) + slow: Slow integration tests (> 5 seconds execution) + critical: Critical path tests for PR validation + external: Tests requiring external services (skip in CI) + unit: Pure unit tests with mocks + integration: Integration tests with real services + benchmark: Performance benchmark tests + tool_specific: Tests specific to individual tools + provider_specific: Tests specific to AI model providers + workflow: Tests for multi-step workflows + security: Security-related tests + performance: Performance testing and optimization + memory: Memory usage and leak tests + concurrency: Tests for concurrent operations + error_handling: Tests focused on error scenarios + edge_case: Tests for boundary conditions + flaky: Tests that may be flaky and need retry + nats: Tests requiring NATS messaging + redis: Tests requiring Redis connection + temporal: Tests requiring Temporal workflow + oauth: Tests for OAuth2/authentication flows + http: Tests for HTTP server functionality + websocket: Tests for WebSocket communication + mcp: Tests for MCP protocol compliance + +# CI-friendly warnings (stricter than dev) +filterwarnings = + error + ignore::UserWarning + ignore::DeprecationWarning:websockets.* + ignore::DeprecationWarning:pydantic.* + ignore::PendingDeprecationWarning + ignore:.*unclosed.*:ResourceWarning + ignore:.*coroutine.*never awaited:RuntimeWarning + ignore:.*Task was destroyed.*:RuntimeWarning + +# CI timeout settings +timeout = 300 +timeout_method = "thread" + +# CI logging settings +log_cli = false +log_cli_level = WARNING +log_cli_format = "%(asctime)s [%(levelname)8s] %(name)s: %(message)s" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" + +# JUnit XML configuration +junit_family = "xunit2" +junit_logging = "system-out" +junit_log_passing_tests = false \ No newline at end of file diff --git a/packages/phenokit-config-kit/pytest/comprehensive.ini b/packages/phenokit-config-kit/pytest/comprehensive.ini new file mode 100644 index 0000000..621ad6c --- /dev/null +++ b/packages/phenokit-config-kit/pytest/comprehensive.ini @@ -0,0 +1,140 @@ +[pytest] +# Comprehensive pytest configuration for enterprise projects +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# Comprehensive options +addopts = + -v + --tb=short + --strict-markers + --strict-config + --maxfail=10 + --durations=10 + --durations-min=2.0 + --cache-clear + # Parallel execution + -n auto + --dist=loadscope + # Coverage analysis + --cov=src + --cov-report=term-missing:skip-covered + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml + --cov-report=json:coverage.json + --cov-fail-under=80 + --cov-branch + # JUnit XML for CI integration + --junitxml=reports/pytest-junit.xml + +# Comprehensive markers +markers = + # Test types + unit: Unit tests (fast, isolated, no I/O, <1s per test) + integration: Integration tests (slower, may use I/O, require services) + e2e: End-to-end tests (slowest, full system workflows) + smoke: Smoke tests for quick validation + regression: Comprehensive regression coverage + + # Performance markers + fast: Fast running tests (< 1 second) + slow: Slow running tests (> 5 seconds) + performance: Performance and benchmarking tests + benchmark: Performance benchmark tests + memory: Memory usage and leak tests + concurrency: Tests for concurrent operations + + # Architecture markers + domain: Domain layer tests (business logic) + application: Application layer tests (use cases) + adapters: Adapter layer tests (external interfaces) + infrastructure: Infrastructure layer tests + + # Execution markers + parallel: Tests that can run in parallel + serial: Tests that must run serially + critical: Critical path tests for PR validation + flaky: Tests that may be flaky and need retry + + # Service markers + external: Tests requiring external services (skip in CI) + database: Database interaction tests + cache: Cache tests + message_broker: Message broker tests + http: HTTP server functionality tests + websocket: WebSocket communication tests + api: HTTP API level tests + + # Security and compliance + security: Security-related tests + auth: Authentication and authorization checks + rls: Tests that validate RLS policies + compliance: Compliance and regulatory tests + + # Quality markers + error_handling: Tests focused on error scenarios + edge_case: Tests for boundary conditions + timeout: Sets timeout for test execution + contract: Contract validation for external interfaces + + # Workflow markers + workflow: Tests for multi-step workflows + migration: Schema/config migration validation + compatibility: Backwards/forwards compatibility coverage + + # Provider-specific markers + tool_specific: Tests specific to individual tools + provider_specific: Tests specific to AI model providers + oauth: Tests for OAuth2/authentication flows + mcp: Tests for MCP protocol compliance + + # Test execution order + order: Test execution order (pytest-ordering plugin) + dependency: Test dependencies (pytest-dependency plugin) + + # Environment markers + ci: Tests that should run in CI + local: Tests that should run locally + dev: Development environment tests + prod: Production environment tests + + # Skip conditions + skip_if_no_oauth: Skip if OAuth not available + skip_if_no_redis: Skip if Redis not available + skip_if_no_nats: Skip if NATS not available + skip_if_no_temporal: Skip if Temporal not available + +# Asyncio configuration +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session + +# Comprehensive warnings +filterwarnings = + error + ignore::UserWarning + ignore::DeprecationWarning:websockets.* + ignore::DeprecationWarning:pydantic.* + ignore::PendingDeprecationWarning + ignore:.*unclosed.*:ResourceWarning + ignore:.*coroutine.*never awaited:RuntimeWarning + ignore:.*Task was destroyed.*:RuntimeWarning + ignore::pytest.PytestAssertRewriteWarning + ignore:cannot collect test class .* because it has a __init__ constructor:pytest.PytestCollectionWarning + +# Timeout settings +timeout = 300 +timeout_method = "thread" + +# Logging settings +log_cli = true +log_cli_level = INFO +log_cli_format = "%(asctime)s [%(levelname)8s] %(name)s: %(message)s" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" + +# JUnit XML configuration +junit_family = "xunit2" +junit_logging = "system-out" +junit_log_passing_tests = false \ No newline at end of file diff --git a/packages/phenokit-config-kit/pytest/data/__init__.py b/packages/phenokit-config-kit/pytest/data/__init__.py new file mode 100644 index 0000000..4269819 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/data/__init__.py @@ -0,0 +1,13 @@ +""" +Test data management for config-kit package. + +This module provides utilities for managing test data across different projects. +""" + +__version__ = "0.1.0" +__all__ = [ + "TestDataFactory", + "TestDataGenerator", + "TestDataManager", + "TestDataValidator", +] diff --git a/packages/phenokit-config-kit/pytest/data/factory.py b/packages/phenokit-config-kit/pytest/data/factory.py new file mode 100644 index 0000000..10944f7 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/data/factory.py @@ -0,0 +1,225 @@ +""" +Test data factory for generating test data. + +This module provides a factory for generating test data across different projects. +""" + +import random +import string +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any + + +@dataclass +class TestDataFactory: + """Factory for generating test data.""" + + seed: int | None = None + _random: random.Random = field(default_factory=random.Random) + + def __post_init__(self): + if self.seed is not None: + self._random.seed(self.seed) + + def random_string(self, length: int = 10, chars: str = None) -> str: + """Generate a random string.""" + if chars is None: + chars = string.ascii_letters + string.digits + return "".join(self._random.choices(chars, k=length)) + + def random_email(self, domain: str = "example.com") -> str: + """Generate a random email address.""" + username = self.random_string(8, string.ascii_lowercase + string.digits) + return f"{username}@{domain}" + + def random_uuid(self) -> str: + """Generate a random UUID.""" + return str(uuid.uuid4()) + + def random_int(self, min_val: int = 0, max_val: int = 100) -> int: + """Generate a random integer.""" + return self._random.randint(min_val, max_val) + + def random_float(self, min_val: float = 0.0, max_val: float = 1.0) -> float: + """Generate a random float.""" + return self._random.uniform(min_val, max_val) + + def random_choice(self, choices: list[Any]) -> Any: + """Choose a random item from a list.""" + return self._random.choice(choices) + + def random_choices(self, choices: list[Any], k: int = 1) -> list[Any]: + """Choose multiple random items from a list.""" + return self._random.choices(choices, k=k) + + def random_bool(self) -> bool: + """Generate a random boolean.""" + return self._random.choice([True, False]) + + def random_datetime(self, start: datetime = None, end: datetime = None) -> datetime: + """Generate a random datetime.""" + if start is None: + start = datetime.now() - timedelta(days=365) + if end is None: + end = datetime.now() + + delta = end - start + random_seconds = self._random.randint(0, int(delta.total_seconds())) + return start + timedelta(seconds=random_seconds) + + def random_date(self, start: datetime = None, end: datetime = None) -> datetime: + """Generate a random date (time set to 00:00:00).""" + dt = self.random_datetime(start, end) + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + def user_data(self, **overrides) -> dict[str, Any]: + """Generate user test data.""" + data = { + "id": self.random_uuid(), + "username": self.random_string(8, string.ascii_lowercase + string.digits), + "email": self.random_email(), + "full_name": f"{self.random_string(6)} {self.random_string(8)}", + "roles": self.random_choices(["user", "admin", "moderator"], k=self.random_int(1, 3)), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + "is_active": self.random_bool(), + } + data.update(overrides) + return data + + def organization_data(self, **overrides) -> dict[str, Any]: + """Generate organization test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Organization {self.random_string(6)}", + "description": f"Test organization description {self.random_string(20)}", + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + "is_active": self.random_bool(), + } + data.update(overrides) + return data + + def project_data(self, organization_id: str = None, **overrides) -> dict[str, Any]: + """Generate project test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Project {self.random_string(6)}", + "description": f"Test project description {self.random_string(20)}", + "status": self.random_choice(["active", "inactive", "archived"]), + "organization_id": organization_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def document_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate document test data.""" + data = { + "id": self.random_uuid(), + "title": f"Test Document {self.random_string(6)}", + "content": f"Test document content {self.random_string(50)}", + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def requirement_data(self, document_id: str = None, **overrides) -> dict[str, Any]: + """Generate requirement test data.""" + data = { + "id": self.random_uuid(), + "title": f"Test Requirement {self.random_string(6)}", + "description": f"Test requirement description {self.random_string(30)}", + "priority": self.random_choice(["low", "medium", "high", "critical"]), + "status": self.random_choice(["draft", "review", "approved", "rejected"]), + "document_id": document_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def test_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate test entity data.""" + data = { + "id": self.random_uuid(), + "name": f"Test {self.random_string(6)}", + "description": f"Test description {self.random_string(20)}", + "status": self.random_choice(["pending", "running", "passed", "failed", "skipped"]), + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def relationship_data(self, source_id: str = None, target_id: str = None, **overrides) -> dict[str, Any]: + """Generate relationship test data.""" + data = { + "id": self.random_uuid(), + "source_id": source_id or self.random_uuid(), + "target_id": target_id or self.random_uuid(), + "relationship_type": self.random_choice(["parent", "child", "sibling", "related"]), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def workflow_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate workflow test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Workflow {self.random_string(6)}", + "description": f"Test workflow description {self.random_string(20)}", + "status": self.random_choice(["draft", "active", "paused", "completed"]), + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def query_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate query test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Query {self.random_string(6)}", + "query": f"SELECT * FROM test_table WHERE id = '{self.random_uuid()}'", + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def create_related_data(self, entity_type: str, count: int = 5, **overrides) -> list[dict[str, Any]]: + """Create related test data.""" + data = [] + for _ in range(count): + if entity_type == "user": + data.append(self.user_data(**overrides)) + elif entity_type == "organization": + data.append(self.organization_data(**overrides)) + elif entity_type == "project": + data.append(self.project_data(**overrides)) + elif entity_type == "document": + data.append(self.document_data(**overrides)) + elif entity_type == "requirement": + data.append(self.requirement_data(**overrides)) + elif entity_type == "test": + data.append(self.test_data(**overrides)) + elif entity_type == "relationship": + data.append(self.relationship_data(**overrides)) + elif entity_type == "workflow": + data.append(self.workflow_data(**overrides)) + elif entity_type == "query": + data.append(self.query_data(**overrides)) + else: + raise ValueError(f"Unknown entity type: {entity_type}") + return data diff --git a/packages/phenokit-config-kit/pytest/fixtures/__init__.py b/packages/phenokit-config-kit/pytest/fixtures/__init__.py new file mode 100644 index 0000000..fe01c35 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/fixtures/__init__.py @@ -0,0 +1,18 @@ +""" +Pytest fixtures for config-kit package. + +This module provides a collection of pytest fixtures that can be used +across different Python projects. +""" + +__version__ = "0.1.0" +__all__ = [ + "cache_fixture", + "database_fixture", + "message_broker_fixture", + "mock_client", + "performance_monitor", + "security_context", + "temp_dir", + "test_data", +] diff --git a/packages/phenokit-config-kit/pytest/fixtures/common.py b/packages/phenokit-config-kit/pytest/fixtures/common.py new file mode 100644 index 0000000..598cde9 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/fixtures/common.py @@ -0,0 +1,226 @@ +""" +Common pytest fixtures for testing. + +This module provides commonly used fixtures across different projects. +""" + +import os +import shutil +import tempfile +from collections.abc import Generator +from pathlib import Path +from typing import Any +from unittest.mock import Mock + +import pytest + + +@pytest.fixture +def temp_dir() -> Generator[Path, None, None]: + """Create a temporary directory for testing.""" + temp_path = Path(tempfile.mkdtemp()) + try: + yield temp_path + finally: + shutil.rmtree(temp_path, ignore_errors=True) + + +@pytest.fixture +def mock_client() -> Mock: + """Create a mock client for testing.""" + client = Mock() + client.call_tool = Mock(return_value={"success": True, "data": {}}) + client.list_tools = Mock(return_value={"tools": []}) + client.list_resources = Mock(return_value={"resources": []}) + return client + + +@pytest.fixture +def test_data() -> dict[str, Any]: + """Provide test data for testing.""" + return { + "user": { + "id": "test-user-123", + "name": "Test User", + "email": "test@example.com", + "roles": ["user"], + }, + "organization": { + "id": "test-org-123", + "name": "Test Organization", + "description": "Test organization for testing", + }, + "project": { + "id": "test-project-123", + "name": "Test Project", + "status": "active", + "organization_id": "test-org-123", + }, + "document": { + "id": "test-doc-123", + "title": "Test Document", + "content": "Test document content", + "project_id": "test-project-123", + }, + } + + +@pytest.fixture +def performance_monitor() -> Generator[dict[str, Any], None, None]: + """Monitor performance during test execution.""" + import time + + import psutil + + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data = { + "start_time": start_time, + "start_memory": start_memory, + "peak_memory": start_memory, + "measurements": [], + } + + def measure(name: str): + current_time = time.time() + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + measurement = { + "name": name, + "timestamp": current_time, + "memory": current_memory, + "duration": current_time - start_time, + } + + monitor_data["measurements"].append(measurement) + + monitor_data["peak_memory"] = max(monitor_data["peak_memory"], current_memory) + + return measurement + + monitor_data["measure"] = measure + + try: + yield monitor_data + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data["end_time"] = end_time + monitor_data["end_memory"] = end_memory + monitor_data["total_duration"] = end_time - start_time + monitor_data["memory_delta"] = end_memory - start_memory + + +@pytest.fixture +def security_context() -> dict[str, Any]: + """Provide security context for testing.""" + return { + "user_id": "test-user-123", + "organization_id": "test-org-123", + "roles": ["user"], + "permissions": ["read", "write"], + "auth_token": "test-token-123", + "session_id": "test-session-123", + } + + +@pytest.fixture +def mock_http_client() -> Mock: + """Create a mock HTTP client for testing.""" + client = Mock() + client.get = Mock(return_value=Mock(status_code=200, json=dict)) + client.post = Mock(return_value=Mock(status_code=201, json=dict)) + client.put = Mock(return_value=Mock(status_code=200, json=dict)) + client.delete = Mock(return_value=Mock(status_code=204, json=dict)) + return client + + +@pytest.fixture +def mock_database() -> Mock: + """Create a mock database for testing.""" + db = Mock() + db.execute = Mock(return_value=Mock(rowcount=1)) + db.fetchone = Mock(return_value={}) + db.fetchall = Mock(return_value=[]) + db.commit = Mock() + db.rollback = Mock() + return db + + +@pytest.fixture +def mock_cache() -> Mock: + """Create a mock cache for testing.""" + cache = Mock() + cache.get = Mock(return_value=None) + cache.set = Mock(return_value=True) + cache.delete = Mock(return_value=True) + cache.clear = Mock(return_value=True) + return cache + + +@pytest.fixture +def mock_message_broker() -> Mock: + """Create a mock message broker for testing.""" + broker = Mock() + broker.publish = Mock(return_value=True) + broker.subscribe = Mock(return_value=Mock()) + broker.unsubscribe = Mock(return_value=True) + return broker + + +@pytest.fixture +def mock_file_system() -> Mock: + """Create a mock file system for testing.""" + fs = Mock() + fs.exists = Mock(return_value=True) + fs.read_text = Mock(return_value="test content") + fs.write_text = Mock(return_value=10) + fs.mkdir = Mock(return_value=True) + fs.rmdir = Mock(return_value=True) + fs.unlink = Mock(return_value=True) + return fs + + +@pytest.fixture +def mock_logger() -> Mock: + """Create a mock logger for testing.""" + logger = Mock() + logger.debug = Mock() + logger.info = Mock() + logger.warning = Mock() + logger.error = Mock() + logger.critical = Mock() + return logger + + +@pytest.fixture +def mock_config() -> Mock: + """Create a mock configuration for testing.""" + config = Mock() + config.get = Mock(return_value="default_value") + config.set = Mock(return_value=True) + config.has = Mock(return_value=True) + return config + + +@pytest.fixture +def mock_environment() -> Generator[dict[str, str], None, None]: + """Create a mock environment for testing.""" + original_env = os.environ.copy() + + test_env = { + "TEST_MODE": "true", + "TEST_DATABASE_URL": "sqlite:///:memory:", + "TEST_CACHE_URL": "memory://", + "TEST_MESSAGE_BROKER_URL": "memory://", + } + + os.environ.update(test_env) + + try: + yield test_env + finally: + os.environ.clear() + os.environ.update(original_env) diff --git a/packages/phenokit-config-kit/pytest/fixtures/performance.py b/packages/phenokit-config-kit/pytest/fixtures/performance.py new file mode 100644 index 0000000..5c74ef9 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/fixtures/performance.py @@ -0,0 +1,183 @@ +""" +Performance testing fixtures for pytest. + +This module provides fixtures specifically for performance testing. +""" + +import threading +import time +from collections.abc import Generator +from contextlib import contextmanager +from typing import Any +from unittest.mock import Mock + +import psutil +import pytest + + +@pytest.fixture +def performance_monitor() -> Generator[dict[str, Any], None, None]: + """Monitor performance during test execution.""" + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data = { + "start_time": start_time, + "start_memory": start_memory, + "peak_memory": start_memory, + "measurements": [], + "threads": [], + } + + def measure(name: str, **kwargs): + current_time = time.time() + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + measurement = { + "name": name, + "timestamp": current_time, + "memory": current_memory, + "duration": current_time - start_time, + **kwargs, + } + + monitor_data["measurements"].append(measurement) + + monitor_data["peak_memory"] = max(monitor_data["peak_memory"], current_memory) + + return measurement + + def start_thread_monitoring(): + """Start background thread monitoring.""" + def monitor(): + while True: + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + monitor_data["peak_memory"] = max(monitor_data["peak_memory"], current_memory) + time.sleep(0.1) + + thread = threading.Thread(target=monitor, daemon=True) + thread.start() + monitor_data["threads"].append(thread) + + monitor_data["measure"] = measure + monitor_data["start_thread_monitoring"] = start_thread_monitoring + + try: + yield monitor_data + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data["end_time"] = end_time + monitor_data["end_memory"] = end_memory + monitor_data["total_duration"] = end_time - start_time + monitor_data["memory_delta"] = end_memory - start_memory + + +@pytest.fixture +def benchmark_data() -> Generator[dict[str, Any], None, None]: + """Provide benchmark data for testing.""" + return { + "iterations": 1000, + "warmup_iterations": 100, + "timeout": 60.0, + "threshold": 1.0, + "results": [], + } + + +@pytest.fixture +def load_test_data() -> Generator[dict[str, Any], None, None]: + """Provide load test data for testing.""" + return { + "users": 10, + "duration": 60, + "ramp_up": 10, + "ramp_down": 10, + "requests_per_second": 100, + "concurrent_requests": 50, + } + + +@pytest.fixture +def memory_profiler() -> Generator[dict[str, Any], None, None]: + """Provide memory profiling capabilities.""" + profiler_data = { + "snapshots": [], + "baseline": None, + "current": None, + } + + def take_snapshot(name: str): + snapshot = { + "name": name, + "timestamp": time.time(), + "memory": psutil.Process().memory_info().rss / 1024 / 1024, + "memory_percent": psutil.Process().memory_percent(), + "cpu_percent": psutil.Process().cpu_percent(), + } + profiler_data["snapshots"].append(snapshot) + profiler_data["current"] = snapshot + return snapshot + + def set_baseline(): + profiler_data["baseline"] = take_snapshot("baseline") + + def get_memory_delta(): + if profiler_data["baseline"] and profiler_data["current"]: + return profiler_data["current"]["memory"] - profiler_data["baseline"]["memory"] + return 0 + + profiler_data["take_snapshot"] = take_snapshot + profiler_data["set_baseline"] = set_baseline + profiler_data["get_memory_delta"] = get_memory_delta + + yield profiler_data + + +@pytest.fixture +def performance_thresholds() -> dict[str, float]: + """Provide performance thresholds for testing.""" + return { + "max_duration": 1.0, # seconds + "max_memory": 100.0, # MB + "max_cpu": 80.0, # percent + "max_memory_delta": 50.0, # MB + } + + +@pytest.fixture +def mock_performance_client() -> Mock: + """Create a mock performance client for testing.""" + client = Mock() + client.benchmark = Mock(return_value={"duration": 0.5, "memory": 50.0}) + client.profile = Mock(return_value={"cpu": 10.0, "memory": 50.0}) + client.load_test = Mock(return_value={"throughput": 100.0, "latency": 0.1}) + return client + + +@contextmanager +def performance_context(name: str, threshold: float = 1.0): + """Context manager for performance monitoring.""" + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + try: + yield + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + duration = end_time - start_time + memory_delta = end_memory - start_memory + + if duration > threshold: + pytest.fail(f"Performance threshold exceeded for {name}: {duration:.2f}s > {threshold}s") + + print(f"Performance: {name} - Duration: {duration:.2f}s, Memory: {memory_delta:.2f}MB") + + +@pytest.fixture +def performance_context_factory(): + """Factory for creating performance contexts.""" + return performance_context diff --git a/packages/phenokit-config-kit/pytest/fixtures/security.py b/packages/phenokit-config-kit/pytest/fixtures/security.py new file mode 100644 index 0000000..e2f5d5e --- /dev/null +++ b/packages/phenokit-config-kit/pytest/fixtures/security.py @@ -0,0 +1,196 @@ +""" +Security testing fixtures for pytest. + +This module provides fixtures specifically for security testing. +""" + +import hashlib +import hmac +from collections.abc import Generator +from typing import Any +from unittest.mock import Mock + +import pytest + + +@pytest.fixture +def security_context() -> Generator[dict[str, Any], None, None]: + """Provide security context for testing.""" + return { + "user_id": "test-user-123", + "organization_id": "test-org-123", + "roles": ["user"], + "permissions": ["read", "write"], + "auth_token": "test-token-123", + "session_id": "test-session-123", + "ip_address": "127.0.0.1", + "user_agent": "test-agent", + "request_id": "test-request-123", + } + + +@pytest.fixture +def mock_auth_client() -> Mock: + """Create a mock authentication client for testing.""" + client = Mock() + client.authenticate = Mock(return_value={"success": True, "user_id": "test-user-123"}) + client.authorize = Mock(return_value={"success": True, "permissions": ["read", "write"]}) + client.validate_token = Mock(return_value={"valid": True, "user_id": "test-user-123"}) + client.refresh_token = Mock(return_value={"success": True, "token": "new-token-123"}) + client.logout = Mock(return_value={"success": True}) + return client + + +@pytest.fixture +def mock_security_scanner() -> Mock: + """Create a mock security scanner for testing.""" + scanner = Mock() + scanner.scan_vulnerabilities = Mock(return_value={"vulnerabilities": []}) + scanner.scan_dependencies = Mock(return_value={"vulnerabilities": []}) + scanner.scan_secrets = Mock(return_value={"secrets": []}) + scanner.scan_code = Mock(return_value={"issues": []}) + return scanner + + +@pytest.fixture +def test_credentials() -> dict[str, str]: + """Provide test credentials for testing.""" + return { + "username": "testuser", + "password": "testpassword123", + "email": "test@example.com", + "api_key": "test-api-key-123", + "secret": "test-secret-123", + } + + +@pytest.fixture +def test_tokens() -> dict[str, str]: + """Provide test tokens for testing.""" + return { + "access_token": "test-access-token-123", + "refresh_token": "test-refresh-token-123", + "id_token": "test-id-token-123", + "csrf_token": "test-csrf-token-123", + } + + +@pytest.fixture +def test_hashes() -> dict[str, str]: + """Provide test hashes for testing.""" + test_string = "test-string-123" + return { + "md5": hashlib.md5(test_string.encode()).hexdigest(), + "sha1": hashlib.sha1(test_string.encode()).hexdigest(), + "sha256": hashlib.sha256(test_string.encode()).hexdigest(), + "sha512": hashlib.sha512(test_string.encode()).hexdigest(), + "hmac": hmac.new(b"secret-key", test_string.encode(), hashlib.sha256).hexdigest(), + } + + +@pytest.fixture +def test_encryption_data() -> dict[str, Any]: + """Provide test encryption data for testing.""" + return { + "plaintext": "test-plaintext-123", + "ciphertext": "encrypted-test-data-123", + "key": "test-encryption-key-123", + "iv": "test-initialization-vector-123", + "algorithm": "AES-256-GCM", + } + + +@pytest.fixture +def test_sql_injection_payloads() -> list[str]: + """Provide test SQL injection payloads for testing.""" + return [ + "'; DROP TABLE users; --", + "1' OR '1'='1", + "1' UNION SELECT * FROM users --", + "1'; INSERT INTO users (username, password) VALUES ('hacker', 'password'); --", + "1' AND (SELECT COUNT(*) FROM users) > 0 --", + ] + + +@pytest.fixture +def test_xss_payloads() -> list[str]: + """Provide test XSS payloads for testing.""" + return [ + "", + "", + "", + "javascript:alert('XSS')", + "", + ] + + +@pytest.fixture +def test_csrf_payloads() -> list[str]: + """Provide test CSRF payloads for testing.""" + return [ + "
", + "
", + "", + ] + + +@pytest.fixture +def test_path_traversal_payloads() -> list[str]: + """Provide test path traversal payloads for testing.""" + return [ + "../../../etc/passwd", + "..\\..\\..\\windows\\system32\\drivers\\etc\\hosts", + "....//....//....//etc/passwd", + "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", + ] + + +@pytest.fixture +def test_command_injection_payloads() -> list[str]: + """Provide test command injection payloads for testing.""" + return [ + "; ls -la", + "| cat /etc/passwd", + "& whoami", + "`id`", + "$(id)", + "; rm -rf /", + ] + + +@pytest.fixture +def security_headers() -> dict[str, str]: + """Provide security headers for testing.""" + return { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "Content-Security-Policy": "default-src 'self'", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "geolocation=(), microphone=(), camera=()", + } + + +@pytest.fixture +def mock_encryption_service() -> Mock: + """Create a mock encryption service for testing.""" + service = Mock() + service.encrypt = Mock(return_value="encrypted-data-123") + service.decrypt = Mock(return_value="decrypted-data-123") + service.generate_key = Mock(return_value="generated-key-123") + service.generate_iv = Mock(return_value="generated-iv-123") + return service + + +@pytest.fixture +def mock_audit_logger() -> Mock: + """Create a mock audit logger for testing.""" + logger = Mock() + logger.log_auth_success = Mock() + logger.log_auth_failure = Mock() + logger.log_permission_denied = Mock() + logger.log_security_event = Mock() + logger.log_data_access = Mock() + logger.log_data_modification = Mock() + return logger diff --git a/packages/phenokit-config-kit/pytest/parallel.ini b/packages/phenokit-config-kit/pytest/parallel.ini new file mode 100644 index 0000000..e89bfd7 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/parallel.ini @@ -0,0 +1,65 @@ +[pytest] +# Parallel execution configuration for different scenarios +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# === PARALLEL EXECUTION PROFILES === + +# Fast unit tests only (no external dependencies) +# Usage: pytest --ini=pytest-parallel.ini -m "fast and not slow and not serial" +addopts = + -v + --strict-markers + --tb=short + --disable-warnings + --strict-config + --maxfail=3 + --durations=5 + --durations-min=1.0 + -n auto + --dist=loadscope + +# Asyncio configuration +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session + +# Warnings for parallel execution +filterwarnings = + ignore::DeprecationWarning:websockets.* + ignore::DeprecationWarning:pydantic.* + +# Parallel execution markers +markers = + fast: marks tests as fast unit tests (< 2s, no external deps, parallel-safe) + unit: marks tests as isolated unit tests (fast, no external dependencies) + integration: marks tests as integration tests that make real API calls + slow: marks tests as slow or long-running (>5 seconds) + serial: marks tests that must run serially due to shared resources + benchmark: marks performance/throughput benchmark tests + no_mock_provider: requires real provider calls (no mocks) + redis: tests that require Redis connection (serial) + nats: tests that require NATS messaging (serial) + oauth: tests for OAuth2/authentication flows + http: tests for HTTP server functionality + websocket: tests for WebSocket communication + mcp: tests for MCP protocol compliance + tool_specific: tests specific to individual tools + provider_specific: tests specific to AI model providers + workflow: tests for multi-step workflows + security: security-related tests + performance: performance testing and optimization + memory: memory usage and leak tests + concurrency: tests for concurrent operations + error_handling: tests focused on error scenarios + edge_case: tests for boundary conditions and edge cases + timeout: sets timeout for test execution + +# === EXECUTION COMMANDS === +# Fast parallel tests: pytest -m "fast and not serial" -n auto +# All unit tests parallel: pytest -m "unit and not serial" -n auto --maxfail=5 +# Serial tests only: pytest -m "serial" -n 0 +# Integration tests: pytest -m "integration" -n 4 --maxfail=2 +# All tests with grouping: pytest -n auto --dist=worksteal --maxfail=10 \ No newline at end of file diff --git a/packages/phenokit-config-kit/pytest/performance.ini b/packages/phenokit-config-kit/pytest/performance.ini new file mode 100644 index 0000000..d12a006 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/performance.ini @@ -0,0 +1,64 @@ +[pytest] +# Performance testing configuration +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# Performance testing options +addopts = + -v + --tb=short + --strict-markers + --maxfail=5 + --durations=20 + --durations-min=0.1 + # Performance-specific options + --benchmark-only + --benchmark-sort=mean + --benchmark-skip + --benchmark-save=performance_results + --benchmark-save-data + # Memory profiling + --profile + --profile-svg + # Coverage for performance tests + --cov=src + --cov-report=term-missing + --cov-report=html:htmlcov + +# Asyncio configuration +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session + +# Performance testing markers +markers = + benchmark: Performance benchmark tests + performance: Performance testing and optimization + memory: Memory usage and leak tests + concurrency: Tests for concurrent operations + load: High concurrency load scenarios + stress: Stress testing under extreme conditions + profiling: Tests that require profiling + slow: Slow running tests (> 5 seconds) + fast: Fast running tests (< 1 second) + unit: Unit tests (fast, isolated, no I/O) + integration: Integration tests (slower, may use I/O) + +# Performance-specific warnings +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning + ignore::UserWarning:pytest_benchmark + ignore::UserWarning:memory_profiler + +# Timeout settings for performance tests +timeout = 600 +timeout_method = "thread" + +# Logging for performance analysis +log_cli = true +log_cli_level = INFO +log_cli_format = "%(asctime)s [%(levelname)8s] %(name)s: %(message)s" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" \ No newline at end of file diff --git a/packages/phenokit-config-kit/pytest/plugins/__init__.py b/packages/phenokit-config-kit/pytest/plugins/__init__.py new file mode 100644 index 0000000..1fe1989 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/plugins/__init__.py @@ -0,0 +1,15 @@ +""" +Pytest plugins for config-kit package. + +This module provides a collection of pytest plugins that can be used +across different Python projects. +""" + +__version__ = "0.1.0" +__all__ = [ + "ArchitecturePlugin", + "CoveragePlugin", + "PerformancePlugin", + "ReportingPlugin", + "SecurityPlugin", +] diff --git a/packages/phenokit-config-kit/pytest/plugins/architecture.py b/packages/phenokit-config-kit/pytest/plugins/architecture.py new file mode 100644 index 0000000..ecd9519 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/plugins/architecture.py @@ -0,0 +1,271 @@ +""" +Architecture testing plugin for pytest. + +This plugin provides architecture fitness tests including: +- File size validation +- Import boundary enforcement +- Dependency direction validation +- Naming convention checks +- Cyclomatic complexity analysis +""" + +import ast +import os +from pathlib import Path + +import pytest + + +class ArchitecturePlugin: + """Pytest plugin for architecture fitness testing.""" + + def __init__(self, config): + self.config = config + self.max_file_size = config.getoption("--max-file-size", default=1000) + self.max_complexity = config.getoption("--max-complexity", default=10) + self.enforce_imports = config.getoption("--enforce-imports", default=True) + self.enforce_dependencies = config.getoption("--enforce-dependencies", default=True) + + @pytest.hookimpl(tryfirst=True) + def pytest_collection_modifyitems(self, config, items): + """Add architecture tests to the test collection.""" + if not self.enforce_imports and not self.enforce_dependencies: + return + + # Add architecture tests + architecture_items = [] + + if self.enforce_imports: + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_import_boundaries", + callobj=self._test_import_boundaries, + markers=[pytest.mark.architecture, pytest.mark.imports], + ), + ) + + if self.enforce_dependencies: + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_dependency_direction", + callobj=self._test_dependency_direction, + markers=[pytest.mark.architecture, pytest.mark.dependencies], + ), + ) + + # Add file size tests + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_file_sizes", + callobj=self._test_file_sizes, + markers=[pytest.mark.architecture, pytest.mark.file_size], + ), + ) + + # Add complexity tests + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_cyclomatic_complexity", + callobj=self._test_cyclomatic_complexity, + markers=[pytest.mark.architecture, pytest.mark.complexity], + ), + ) + + items.extend(architecture_items) + + def _test_import_boundaries(self): + """Test that import boundaries are respected.""" + violations = [] + + # Define allowed import patterns + allowed_patterns = [ + r"^src\.", + r"^tests\.", + r"^conftest$", + ] + + # Check each Python file + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if not self._is_allowed_import(alias.name, allowed_patterns): + violations.append(f"{py_file}: {alias.name}") + elif isinstance(node, ast.ImportFrom): + if node.module and not self._is_allowed_import(node.module, allowed_patterns): + violations.append(f"{py_file}: {node.module}") + + if violations: + pytest.fail("Import boundary violations found:\n" + "\n".join(violations)) + + def _test_dependency_direction(self): + """Test that dependency direction is respected.""" + violations = [] + + # Define dependency rules + dependency_rules = { + "src.domain": [], # Domain should not depend on anything + "src.application": ["src.domain"], # Application can depend on domain + "src.adapters": ["src.domain", "src.application"], # Adapters can depend on domain and application + "src.infrastructure": ["src.domain", "src.application", "src.adapters"], # Infrastructure can depend on all + } + + for py_file in self._get_python_files(): + file_path = str(py_file) + if not file_path.startswith("src/"): + continue + + # Determine which layer this file belongs to + file_layer = self._get_file_layer(file_path) + if not file_layer: + continue + + # Check imports against dependency rules + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + import_layer = self._get_import_layer(node.module) + if import_layer and not self._is_allowed_dependency(file_layer, import_layer, dependency_rules): + violations.append(f"{py_file}: {file_layer} -> {import_layer} ({node.module})") + + if violations: + pytest.fail("Dependency direction violations found:\n" + "\n".join(violations)) + + def _test_file_sizes(self): + """Test that file sizes are within limits.""" + violations = [] + + for py_file in self._get_python_files(): + line_count = sum(1 for _ in open(py_file, encoding="utf-8")) + if line_count > self.max_file_size: + violations.append(f"{py_file}: {line_count} lines (max: {self.max_file_size})") + + if violations: + pytest.fail("File size violations found:\n" + "\n".join(violations)) + + def _test_cyclomatic_complexity(self): + """Test that cyclomatic complexity is within limits.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + complexity = self._calculate_complexity(node) + if complexity > self.max_complexity: + violations.append(f"{py_file}:{node.lineno} {node.name}: {complexity} (max: {self.max_complexity})") + + if violations: + pytest.fail("Cyclomatic complexity violations found:\n" + "\n".join(violations)) + + def _get_python_files(self) -> list[Path]: + """Get all Python files in the project.""" + python_files = [] + for root, dirs, files in os.walk("."): + # Skip certain directories + dirs[:] = [d for d in dirs if d not in {".git", "__pycache__", ".pytest_cache", "htmlcov", "dist", "build"}] + + for file in files: + if file.endswith(".py"): + python_files.append(Path(root) / file) + + return python_files + + def _is_allowed_import(self, import_name: str, allowed_patterns: list[str]) -> bool: + """Check if an import is allowed based on patterns.""" + import re + return any(re.match(pattern, import_name) for pattern in allowed_patterns) + + def _get_file_layer(self, file_path: str) -> str | None: + """Determine which architectural layer a file belongs to.""" + if "src/domain" in file_path: + return "src.domain" + if "src/application" in file_path: + return "src.application" + if "src/adapters" in file_path: + return "src.adapters" + if "src/infrastructure" in file_path: + return "src.infrastructure" + return None + + def _get_import_layer(self, import_name: str) -> str | None: + """Determine which architectural layer an import belongs to.""" + if import_name.startswith("src.domain"): + return "src.domain" + if import_name.startswith("src.application"): + return "src.application" + if import_name.startswith("src.adapters"): + return "src.adapters" + if import_name.startswith("src.infrastructure"): + return "src.infrastructure" + return None + + def _is_allowed_dependency(self, from_layer: str, to_layer: str, rules: dict[str, list[str]]) -> bool: + """Check if a dependency from one layer to another is allowed.""" + allowed_deps = rules.get(from_layer, []) + return to_layer in allowed_deps + + def _calculate_complexity(self, node: ast.AST) -> 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)) or isinstance(child, ast.ExceptHandler): + complexity += 1 + elif isinstance(child, ast.BoolOp): + complexity += len(child.values) - 1 + + return complexity + + +def pytest_addoption(parser): + """Add command line options for architecture testing.""" + parser.addoption( + "--max-file-size", + type=int, + default=1000, + help="Maximum allowed lines per file", + ) + parser.addoption( + "--max-complexity", + type=int, + default=10, + help="Maximum allowed cyclomatic complexity", + ) + parser.addoption( + "--enforce-imports", + action="store_true", + default=True, + help="Enforce import boundaries", + ) + parser.addoption( + "--enforce-dependencies", + action="store_true", + default=True, + help="Enforce dependency direction", + ) + + +def pytest_configure(config): + """Configure the architecture plugin.""" + config.pluginmanager.register(ArchitecturePlugin(config), "architecture") diff --git a/packages/phenokit-config-kit/pytest/plugins/performance.py b/packages/phenokit-config-kit/pytest/plugins/performance.py new file mode 100644 index 0000000..268ddc3 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/plugins/performance.py @@ -0,0 +1,166 @@ +""" +Performance testing plugin for pytest. + +This plugin provides performance testing capabilities including: +- Benchmark testing +- Memory profiling +- Performance regression detection +- Load testing utilities +""" + +import threading +import time +from collections.abc import Callable +from contextlib import contextmanager + +import psutil +import pytest + + +class PerformancePlugin: + """Pytest plugin for performance testing.""" + + def __init__(self, config): + self.config = config + self.benchmark_threshold = config.getoption("--benchmark-threshold", default=1.0) + self.memory_threshold = config.getoption("--memory-threshold", default=100) # MB + self.performance_data = {} + + @pytest.hookimpl(tryfirst=True) + def pytest_runtest_setup(self, item): + """Setup performance monitoring for each test.""" + if hasattr(item, "get_closest_marker") and item.get_closest_marker("performance"): + self.performance_data[item.nodeid] = { + "start_time": time.time(), + "start_memory": psutil.Process().memory_info().rss / 1024 / 1024, # MB + "peak_memory": 0, + } + + @pytest.hookimpl(tryfirst=True) + def pytest_runtest_teardown(self, item): + """Teardown performance monitoring for each test.""" + if item.nodeid in self.performance_data: + data = self.performance_data[item.nodeid] + data["end_time"] = time.time() + data["duration"] = data["end_time"] - data["start_time"] + data["end_memory"] = psutil.Process().memory_info().rss / 1024 / 1024 # MB + data["memory_delta"] = data["end_memory"] - data["start_memory"] + + @pytest.hookimpl(tryfirst=True) + def pytest_runtest_call(self, item): + """Monitor performance during test execution.""" + if item.nodeid in self.performance_data: + # Monitor memory usage during test execution + def monitor_memory(): + while item.nodeid in self.performance_data: + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + self.performance_data[item.nodeid]["peak_memory"] = max(self.performance_data[item.nodeid]["peak_memory"], current_memory) + time.sleep(0.1) + + monitor_thread = threading.Thread(target=monitor_memory, daemon=True) + monitor_thread.start() + + def pytest_collection_modifyitems(self, config, items): + """Add performance validation tests.""" + performance_items = [] + + # Add benchmark validation + performance_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_benchmark_performance", + callobj=self._test_benchmark_performance, + markers=[pytest.mark.performance, pytest.mark.benchmark], + ), + ) + + # Add memory usage validation + performance_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_memory_usage", + callobj=self._test_memory_usage, + markers=[pytest.mark.performance, pytest.mark.memory], + ), + ) + + items.extend(performance_items) + + def _test_benchmark_performance(self): + """Test that benchmark performance meets thresholds.""" + violations = [] + + for test_id, data in self.performance_data.items(): + if "duration" in data and data["duration"] > self.benchmark_threshold: + violations.append(f"{test_id}: {data['duration']:.2f}s (threshold: {self.benchmark_threshold}s)") + + if violations: + pytest.fail("Benchmark performance violations found:\n" + "\n".join(violations)) + + def _test_memory_usage(self): + """Test that memory usage is within thresholds.""" + violations = [] + + for test_id, data in self.performance_data.items(): + if "peak_memory" in data and data["peak_memory"] > self.memory_threshold: + violations.append(f"{test_id}: {data['peak_memory']:.2f}MB (threshold: {self.memory_threshold}MB)") + + if violations: + pytest.fail("Memory usage violations found:\n" + "\n".join(violations)) + + +@contextmanager +def performance_monitor(test_name: str, threshold: float = 1.0): + """Context manager for monitoring performance of code blocks.""" + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + try: + yield + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + duration = end_time - start_time + memory_delta = end_memory - start_memory + + if duration > threshold: + pytest.fail(f"Performance threshold exceeded: {duration:.2f}s > {threshold}s") + + print(f"Performance: {test_name} - Duration: {duration:.2f}s, Memory: {memory_delta:.2f}MB") + + +def benchmark_test(func: Callable) -> Callable: + """Decorator for marking functions as benchmark tests.""" + return pytest.mark.performance(func) + + +def memory_test(func: Callable) -> Callable: + """Decorator for marking functions as memory tests.""" + return pytest.mark.memory(func) + + +def load_test(users: int = 10, duration: int = 60) -> Callable: + """Decorator for marking functions as load tests.""" + return pytest.mark.performance(pytest.mark.parametrize("load_users", [users])(func)) + + +def pytest_addoption(parser): + """Add command line options for performance testing.""" + parser.addoption( + "--benchmark-threshold", + type=float, + default=1.0, + help="Maximum allowed execution time for benchmark tests (seconds)", + ) + parser.addoption( + "--memory-threshold", + type=float, + default=100, + help="Maximum allowed memory usage (MB)", + ) + + +def pytest_configure(config): + """Configure the performance plugin.""" + config.pluginmanager.register(PerformancePlugin(config), "performance") diff --git a/packages/phenokit-config-kit/pytest/plugins/security.py b/packages/phenokit-config-kit/pytest/plugins/security.py new file mode 100644 index 0000000..b1d9690 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/plugins/security.py @@ -0,0 +1,243 @@ +""" +Security testing plugin for pytest. + +This plugin provides security testing capabilities including: +- Security vulnerability scanning +- Authentication testing +- Authorization testing +- Input validation testing +- Security compliance checking +""" + +import ast +import re +from pathlib import Path + +import pytest + + +class SecurityPlugin: + """Pytest plugin for security testing.""" + + def __init__(self, config): + self.config = config + self.security_rules = self._load_security_rules() + self.vulnerability_patterns = self._load_vulnerability_patterns() + + def _load_security_rules(self) -> dict[str, list[str]]: + """Load security rules configuration.""" + return { + "forbidden_imports": [ + "pickle", + "marshal", + "shelve", + "dbm", + "sqlite3", + "subprocess", + "os.system", + "eval", + "exec", + "compile", + ], + "forbidden_functions": [ + "eval", + "exec", + "compile", + "input", + "raw_input", + "reload", + "__import__", + ], + "required_imports": [ + "hashlib", + "secrets", + "hmac", + ], + "password_patterns": [ + r"password\s*=\s*['\"][^'\"]+['\"]", + r"passwd\s*=\s*['\"][^'\"]+['\"]", + r"pwd\s*=\s*['\"][^'\"]+['\"]", + ], + "hardcoded_secrets": [ + r"api_key\s*=\s*['\"][^'\"]+['\"]", + r"secret\s*=\s*['\"][^'\"]+['\"]", + r"token\s*=\s*['\"][^'\"]+['\"]", + r"key\s*=\s*['\"][^'\"]+['\"]", + ], + } + + def _load_vulnerability_patterns(self) -> dict[str, str]: + """Load vulnerability detection patterns.""" + return { + "sql_injection": r"(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER).*\+.*%", + "xss": r".*", + "path_traversal": r"\.\./", + "command_injection": r"(os\.system|subprocess\.call|subprocess\.run)", + "unsafe_deserialization": r"(pickle\.loads|marshal\.loads)", + "weak_crypto": r"(md5|sha1|des|rc4)", + "hardcoded_credentials": r"(username|password|api_key|secret)\s*=\s*['\"][^'\"]+['\"]", + } + + @pytest.hookimpl(tryfirst=True) + def pytest_collection_modifyitems(self, config, items): + """Add security validation tests.""" + security_items = [] + + # Add vulnerability scanning + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_vulnerability_scan", + callobj=self._test_vulnerability_scan, + markers=[pytest.mark.security, pytest.mark.vulnerability], + ), + ) + + # Add forbidden imports check + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_forbidden_imports", + callobj=self._test_forbidden_imports, + markers=[pytest.mark.security, pytest.mark.imports], + ), + ) + + # Add hardcoded secrets check + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_hardcoded_secrets", + callobj=self._test_hardcoded_secrets, + markers=[pytest.mark.security, pytest.mark.secrets], + ), + ) + + # Add password security check + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_password_security", + callobj=self._test_password_security, + markers=[pytest.mark.security, pytest.mark.passwords], + ), + ) + + items.extend(security_items) + + def _test_vulnerability_scan(self): + """Scan code for common vulnerabilities.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + content = f.read() + + for vuln_type, pattern in self.vulnerability_patterns.items(): + matches = re.findall(pattern, content, re.IGNORECASE) + if matches: + violations.append(f"{py_file}: {vuln_type} - {matches}") + + if violations: + pytest.fail("Security vulnerabilities found:\n" + "\n".join(violations)) + + def _test_forbidden_imports(self): + """Check for forbidden imports that could be security risks.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name in self.security_rules["forbidden_imports"]: + violations.append(f"{py_file}: Forbidden import '{alias.name}'") + elif isinstance(node, ast.ImportFrom): + if node.module in self.security_rules["forbidden_imports"]: + violations.append(f"{py_file}: Forbidden import '{node.module}'") + + if violations: + pytest.fail("Forbidden imports found:\n" + "\n".join(violations)) + + def _test_hardcoded_secrets(self): + """Check for hardcoded secrets in the code.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + content = f.read() + + for pattern in self.security_rules["hardcoded_secrets"]: + matches = re.findall(pattern, content, re.IGNORECASE) + if matches: + violations.append(f"{py_file}: Hardcoded secret - {matches}") + + if violations: + pytest.fail("Hardcoded secrets found:\n" + "\n".join(violations)) + + def _test_password_security(self): + """Check for insecure password handling.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + content = f.read() + + for pattern in self.security_rules["password_patterns"]: + matches = re.findall(pattern, content, re.IGNORECASE) + if matches: + violations.append(f"{py_file}: Insecure password handling - {matches}") + + if violations: + pytest.fail("Insecure password handling found:\n" + "\n".join(violations)) + + def _get_python_files(self) -> list[Path]: + """Get all Python files in the project.""" + python_files = [] + for root, dirs, files in Path().rglob("*.py"): + # Skip certain directories + if any(skip in str(root) for skip in (".git", "__pycache__", ".pytest_cache", "htmlcov", "dist", "build")): + continue + python_files.append(root) + + return python_files + + +def security_test(func: Callable) -> Callable: + """Decorator for marking functions as security tests.""" + return pytest.mark.security(func) + + +def auth_test(func: Callable) -> Callable: + """Decorator for marking functions as authentication tests.""" + return pytest.mark.auth(func) + + +def injection_test(func: Callable) -> Callable: + """Decorator for marking functions as injection tests.""" + return pytest.mark.injection(func) + + +def xss_test(func: Callable) -> Callable: + """Decorator for marking functions as XSS tests.""" + return pytest.mark.xss(func) + + +def csrf_test(func: Callable) -> Callable: + """Decorator for marking functions as CSRF tests.""" + return pytest.mark.csrf(func) + + +def sql_injection_test(func: Callable) -> Callable: + """Decorator for marking functions as SQL injection tests.""" + return pytest.mark.sql_injection(func) + + +def pytest_configure(config): + """Configure the security plugin.""" + config.pluginmanager.register(SecurityPlugin(config), "security") diff --git a/packages/phenokit-config-kit/pytest/security.ini b/packages/phenokit-config-kit/pytest/security.ini new file mode 100644 index 0000000..f6d4da8 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/security.ini @@ -0,0 +1,75 @@ +[pytest] +# Security testing configuration +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# Security testing options +addopts = + -v + --tb=short + --strict-markers + --maxfail=5 + --durations=10 + --durations-min=1.0 + # Security-specific options + --bandit + --bandit-config=.bandit + --safety + --safety-json + # Coverage for security tests + --cov=src + --cov-report=term-missing + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml + +# Asyncio configuration +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session + +# Security testing markers +markers = + security: Security-related tests + auth: Authentication and authorization checks + rls: Tests that validate RLS policies + compliance: Compliance and regulatory tests + vulnerability: Vulnerability testing + penetration: Penetration testing + injection: Injection attack testing + xss: Cross-site scripting testing + csrf: Cross-site request forgery testing + sql_injection: SQL injection testing + authentication: Authentication testing + authorization: Authorization testing + encryption: Encryption testing + hashing: Hashing testing + jwt: JWT token testing + oauth: OAuth2/authentication flows + session: Session management testing + input_validation: Input validation testing + output_encoding: Output encoding testing + error_handling: Security error handling + logging: Security logging testing + audit: Security audit testing + unit: Unit tests (fast, isolated, no I/O) + integration: Integration tests (slower, may use I/O) + +# Security-specific warnings +filterwarnings = + error + ignore::DeprecationWarning + ignore::PendingDeprecationWarning + ignore::UserWarning:bandit + ignore::UserWarning:safety + +# Timeout settings for security tests +timeout = 300 +timeout_method = "thread" + +# Logging for security analysis +log_cli = true +log_cli_level = WARNING +log_cli_format = "%(asctime)s [%(levelname)8s] %(name)s: %(message)s" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index dc24c8a..c536849 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,28 @@ readme = 'README.md' license = { text = 'MIT' } requires-python = '>=3.10' +# Root has no installable Python code — it's a uv workspace aggregator only. +# `tool.uv.package = false` already prevents IMPLICIT uv builds (`uv sync`, +# `uv run`), but EXPLICIT pip installs (`pip install `, +# `uv pip install `) ignore that flag and fall back to +# setuptools auto-discovery, which then refuses to build because the repo +# is a flat layout containing both `okf/` and `packages/` top-level +# directories. The minimal non-disruptive fix is to declare setuptools +# discovery rules that match nothing — no `__init__.py`-bearing top-level +# Python module is shipped from the root. Per-kit installs still come +# from `[tool.uv.workspace] members`. +# +# Verified working pattern, upstream discussion: astral-sh/uv#12352 +# (`[tool.setuptools] py-modules = []` suppresses the multi-package error +# because setuptools has no top-level modules to look for). +[tool.setuptools] +py-modules = [] + +[tool.setuptools.packages.find] +where = ["."] +include = [] +namespaces = false + [tool.uv] package = false @@ -52,6 +74,7 @@ phenotype-id = { workspace = true } phenotype-logging = { workspace = true } phenotype-py-kit = { workspace = true } phenotype-testing = { workspace = true } +phenokit-config-kit = { workspace = true } qa-kit = { workspace = true } [tool.uv.workspace] @@ -86,6 +109,11 @@ members = [ 'packages/phenotype-py-kit', 'packages/phenotype-testing', 'packages/phenotype-config', + # phenokit-config-kit: intake from KooshaPari/phenotype-sdk Phase 3 (decomp). + # Linting/pre-commit/pytest scaffolds — intentionally distinct from + # packages/phenotype-config (which is Pydantic-settings). See packages/ + # phenokit-config-kit/ORIGIN.md. + 'packages/phenokit-config-kit', ] exclude = [ 'packages/auth-kit', diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_install_path.py b/tests/test_install_path.py new file mode 100644 index 0000000..4917e18 --- /dev/null +++ b/tests/test_install_path.py @@ -0,0 +1,179 @@ +"""Regression tests for upstream packaging flat-layout defect. + +Background +---------- + +The consolidated workspace root has ``okf/`` and ``packages/`` as +top-level directories, so external consumers running +``uv pip install `` or ``pip install `` +on the upstream repo used to fail with:: + + error: Multiple top-level packages discovered in a flat-layout: + ['okf', 'packages'] + +because setuptools' default auto-discovery conflicted on those two +directories. The root project is a uv workspace aggregator only (no +installable Python module of its own), so ``[tool.uv] package = false`` +stops implicit uv builds, but explicit installs ignore that flag. + +These tests pin the desired end-state: the workspace must be installable +as a path source (no flat-layout error, exit 0) in a fresh venv. + +Traces to: AUDIT-LANE-FLAT-LAYOUT-001 (Phenotype-python-sdk packaging +audit, second lane after PR #42 ``fix/auth-kit-gitlink``). +""" + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +PYPROJECT = REPO_ROOT / "pyproject.toml" + +# A venv creation on macOS routinely takes ~1.5s and pip install ~2.5s, +# so give the subprocess a generous budget for CI runners. +_INSTALL_TIMEOUT_SECS = 120 + + +def _uv_available() -> bool: + return shutil.which("uv") is not None + + +def _create_temp_venv(python_version: str = "3.13") -> tuple[Path, Path]: + """Create an ephemeral Python venv at *tempdir/.venv* via uv. + + Returns ``(tempdir, venv_python)``. The caller is responsible for + cleaning up *tempdir* via :func:`_cleanup_temp_venv`. + """ + tempdir = Path(tempfile.mkdtemp(prefix="phenotype-sdk-install-")) + venv_dir = tempdir / ".venv" + proc = subprocess.run( + ( + "uv", + "venv", + "--python", + python_version, + str(venv_dir), + ), + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0, ( + f"uv venv creation failed (rc={proc.returncode}):\n" + f"stdout={proc.stdout}\nstderr={proc.stderr}" + ) + return tempdir, venv_dir + + +def _cleanup_temp_venv(tempdir: Path) -> None: + """Best-effort teardown of a temp venv directory tree.""" + if not tempdir.exists(): + return + shutil.rmtree(tempdir, ignore_errors=True) + + +def _run_uv_pip_install( + venv_python: Path, + extra_args: tuple[str, ...] = (), +) -> subprocess.CompletedProcess[str]: + """Run ``uv pip install `` against the venv.""" + cmd = [ + "uv", + "pip", + "install", + "--python", + str(venv_python), + *extra_args, + str(REPO_ROOT), + ] + return subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + timeout=_INSTALL_TIMEOUT_SECS, + ) + + +# Skip the entire module if uv is missing — this is an upstream packaging +# regression, the test is meaningless without uv available. +pytestmark = pytest.mark.skipif( + not _uv_available(), + reason="uv CLI not available on PATH; install uv to run packaging tests", +) + + +@pytest.fixture +def temp_venv(): + """Yield ``(tempdir, venv_python)`` for one test, cleanup on exit.""" + tempdir, venv_dir = _create_temp_venv("3.13") + venv_python = venv_dir / "bin" / "python" + try: + yield tempdir, venv_python + finally: + _cleanup_temp_venv(tempdir) + + +def test_workspace_installs_as_path_source(temp_venv) -> None: + """``uv pip install `` completes with exit 0 in a fresh venv. + + Regression: previously failed with ``Multiple top-level packages + discovered in a flat-layout: ['okf', 'packages']``. + """ + _, venv_python = temp_venv + result = _run_uv_pip_install(venv_python) + + assert result.returncode == 0, ( + f"uv pip install failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + +def test_workspace_install_does_not_emit_flat_layout_error(temp_venv) -> None: + """The flat-layout auto-discovery error string never appears, even + when the install fails for unrelated reasons.""" + _, venv_python = temp_venv + result = _run_uv_pip_install(venv_python) + + combined = result.stdout + "\n" + result.stderr + assert "Multiple top-level packages discovered in a flat-layout" not in combined, ( + "flat-layout auto-discovery regressed:\n" + combined + ) + assert "['okf', 'packages']" not in combined, ( + "flat-layout auto-discovery regressed (conflict list leaked):\n" + combined + ) + + +def test_workspace_editable_install_succeeds(temp_venv) -> None: + """``uv pip install -e `` (editable) also completes cleanly.""" + _, venv_python = temp_venv + result = _run_uv_pip_install(venv_python, extra_args=("-e",)) + + assert result.returncode == 0, ( + f"uv pip install -e failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + assert "Multiple top-level packages" not in (result.stdout + result.stderr), ( + "editable install hit flat-layout error:\n" + result.stderr + ) + + +def test_pyproject_declares_empty_tool_setuptools() -> None: + """Pin the chosen fix: ``[tool.setuptools] py-modules = []`` plus a + no-op ``packages.find`` table that disables auto-discovery.""" + text = PYPROJECT.read_text() + assert "[tool.setuptools]" in text, ( + "missing [tool.setuptools] section in root pyproject.toml" + ) + assert "py-modules = []" in text, ( + "[tool.setuptools] should declare py-modules = [] to suppress " + "flat-layout auto-discovery" + ) + assert "[tool.setuptools.packages.find]" in text, ( + "missing [tool.setuptools.packages.find] section; cannot pin " + "no-package discovery for consumers" + )