diff --git a/.docker/ci-entrypoint.sh b/.docker/ci-entrypoint.sh new file mode 100755 index 0000000..1439ed2 --- /dev/null +++ b/.docker/ci-entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +# Install system dependencies quietly +apt-get update -qq +apt-get install -y -qq make > /dev/null 2>&1 + +# Install Python dependencies quietly +pip install -q -e . 2>/dev/null +pip install -q mypy pytest pytest-asyncio pytest-cov black ruff 2>/dev/null + +# Execute the passed command +exec "$@" \ No newline at end of file diff --git a/.github/AUTH_SETUP.md b/.github/AUTH_SETUP.md new file mode 100644 index 0000000..1381689 --- /dev/null +++ b/.github/AUTH_SETUP.md @@ -0,0 +1,102 @@ +# Pattern Stack Authentication Setup + +This document explains how to set up authentication for Pattern Stack repositories to access private dependencies. + +## Current Setup: Service Account + PAT + +### 1. Create Service Account (One-time per organization) + +1. **Create GitHub Account** + - Create a new GitHub account: `pattern-stack-ci` + - Use an email like `ci@pattern-stack.com` + +2. **Add to Organization** + - Invite `pattern-stack-ci` to the `pattern-stack` organization + - Grant `Read` access to repositories that need to be accessed by CI + - Specifically ensure access to: + - `pattern-stack/geography-patterns` + - `pattern-stack/backend-patterns` + +### 2. Generate Personal Access Token + +1. **Login as Service Account** + - Login to GitHub as `pattern-stack-ci` + +2. **Create PAT** + - Go to Settings → Developer settings → Personal access tokens → Tokens (classic) + - Click "Generate new token (classic)" + - **Name**: `Pattern Stack CI Access` + - **Expiration**: 1 year (set calendar reminder to rotate) + - **Scopes**: + - ✅ `repo` (Full control of private repositories) + - Generate and copy the token + +### 3. Add to Repository Secrets + +For each repository that needs access: + +1. Go to repository Settings → Secrets and variables → Actions +2. Click "New repository secret" +3. **Name**: `PATTERN_STACK_TOKEN` +4. **Value**: The PAT from step 2 +5. Save + +### 4. Verify Setup + +The workflows should now: +- Use `PATTERN_STACK_TOKEN` for checkout and git configuration +- Successfully install dependencies from private repositories +- Pass all CI checks + +## Auth Pattern Used in Workflows + +All workflows use this consistent pattern: + +```yaml +steps: +- uses: actions/checkout@v4 + with: + token: ${{ secrets.PATTERN_STACK_TOKEN }} + +- name: Configure git for private repo access + run: | + git config --global url."https://x-access-token:${{ secrets.PATTERN_STACK_TOKEN }}@github.com/".insteadOf "https://github.com/" + +- name: Install dependencies + run: | + uv sync --frozen + # Dependencies from private repos now work +``` + +## Future Migration: GitHub App + +When scaling to multiple repositories, we'll migrate to a GitHub App approach: + +1. **Benefits**: Better security, automatic token rotation, granular permissions +2. **Implementation**: Pattern Stack tooling will automate the creation and installation +3. **Migration**: Seamless - workflows use same `PATTERN_STACK_TOKEN` interface + +## Troubleshooting + +### Common Issues + +1. **"fatal: could not read Username"** + - Verify `PATTERN_STACK_TOKEN` secret exists in repository + - Check service account has access to target repositories + - Verify PAT has `repo` scope + +2. **PAT Expired** + - Generate new PAT with same scopes + - Update `PATTERN_STACK_TOKEN` secret in all repositories + - Set calendar reminder for next rotation + +3. **403 Forbidden** + - Service account needs to be added to private repositories + - Check organization membership and repository access + +### Security Notes + +- PAT has broad access - rotate regularly (annually) +- Only add to repositories that need private dependency access +- Consider GitHub App migration for better security posture +- Monitor usage in organization audit logs \ No newline at end of file diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000..1ca6a9f --- /dev/null +++ b/.github/README.md @@ -0,0 +1,84 @@ +# GitHub Actions Workflows + +This directory contains CI/CD workflows for the geography-patterns monorepo. + +## Workflow Structure + +### Per-Project Testing +- **`test-wof-explorer.yml`** - Tests for WOF Explorer package +- **`test-geo-platform.yml`** - Tests for Geo Platform package + +### Quality Checks +- **`quality-checks.yml`** - Linting, type checking, and formatting checks across both packages + +### Orchestration +- **`ci.yml`** - Main CI workflow that runs all checks together + +## Workflow Details + +### Test WOF Explorer (`test-wof-explorer.yml`) +- **Triggers**: Changes to `wof-explorer/` directory, workflow file, or dependencies +- **Python versions**: 3.11, 3.12, 3.13 +- **Test database**: Downloads Barbados WOF database for integration tests +- **Commands**: + - `uv run pytest tests/ -v` + - `uv run pytest tests/test_examples.py -v` + +### Test Geo Platform (`test-geo-platform.yml`) +- **Triggers**: Changes to `geo-platform/` directory, workflow file, or dependencies +- **Python versions**: 3.11, 3.12, 3.13 +- **Services**: PostgreSQL with PostGIS extension +- **Commands**: + - `uv run pytest __tests__/unit/ -v` + - `uv run pytest __tests__/integration/ -v` + - `uv run pytest __tests__/ -v` + +### Quality Checks (`quality-checks.yml`) +- **Triggers**: All pushes and PRs +- **Matrix**: Runs for both `wof-explorer` and `geo-platform` +- **Jobs**: + - **Lint**: `uv run ruff check .` + - **Typecheck**: `uv run mypy src/` + - **Format Check**: `uv run ruff format --check .` (+ black for WOF Explorer) + +### Main CI (`ci.yml`) +- **Triggers**: Pushes to main/develop branches, all PRs +- **Strategy**: Orchestrates all other workflows +- **Final check**: Ensures all sub-workflows pass before marking CI as successful + +## Quality Standards + +### Expected Results +- **Geo Platform**: All checks should pass (0 linting issues, 0 type issues) +- **WOF Explorer**: Known issues documented (41 linting issues, 343 type issues) + +### Failure Handling +- Geo Platform failures block CI +- WOF Explorer quality issues are documented but don't block CI (`continue-on-error: true`) +- Test failures always block CI for both packages + +## Local Development + +Run the same checks locally using Make commands: + +```bash +# Run all checks +make check + +# Per-package testing +make test-wof +make test-geo + +# Quality checks +make lint +make typecheck +make format +``` + +## Path-Based Triggers + +Workflows are optimized to only run when relevant files change: + +- Package-specific workflows only trigger on changes to their respective directories +- Quality checks run on all changes +- Dependencies changes (pyproject.toml, uv.lock) trigger relevant workflows \ No newline at end of file diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..2e4e65d --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,16 @@ +name: Setup Environment +description: Setup cli-patterns environment + +inputs: + python-version: + description: Python version + default: '3.9' + +runs: + using: composite + steps: + # In future, this would be: pattern-stack/actions/setup@v1 + # For now, use local pattern-stack standard + - uses: ./.github/workflows/pattern-stack/setup + with: + python-version: ${{ inputs.python-version }} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..34774f4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + workflow_dispatch: + inputs: + use_docker: + description: 'Run tests in Docker' + type: boolean + default: false + +env: + PYTHONPATH: src + +jobs: + quality: + name: Quality Checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: make quality + + test: + name: Test - ${{ matrix.suite }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + suite: [unit, integration, parser, executor, design] + + steps: + - uses: actions/checkout@v4 + + # Native path (default) + - if: ${{ !inputs.use_docker }} + uses: ./.github/actions/setup + - if: ${{ !inputs.use_docker }} + run: make test-${{ matrix.suite }} + + # Docker path (on demand) + - if: ${{ inputs.use_docker }} + run: | + docker compose -f docker-compose.ci.yml run \ + ci make test-${{ matrix.suite }} + + test-fast: + name: Fast Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: make test-fast + + # Python compatibility check (on main branch) + compatibility: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + name: Python ${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.9", "3.11", "3.13"] + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + with: + python-version: ${{ matrix.python }} + - run: make test-fast + + # Future: Performance benchmarks + benchmark: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + name: Performance Benchmark + runs-on: ubuntu-latest + continue-on-error: true # Don't fail CI if benchmarks regress (for now) + + steps: + - uses: actions/checkout@v4 + - name: Run benchmarks in consistent environment + run: | + docker compose -f docker-compose.ci.yml run \ + benchmark make benchmark || echo "No benchmarks yet" \ No newline at end of file diff --git a/.github/workflows/pattern-stack/setup/action.yml b/.github/workflows/pattern-stack/setup/action.yml new file mode 100644 index 0000000..b982034 --- /dev/null +++ b/.github/workflows/pattern-stack/setup/action.yml @@ -0,0 +1,26 @@ +name: Pattern Stack Environment Setup +description: Standard environment setup for Pattern Stack projects + +inputs: + python-version: + description: Python version to use + default: '3.9' + +runs: + using: composite + steps: + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: Setup Python + shell: bash + run: uv python install ${{ inputs.python-version }} + + - name: Install dependencies + shell: bash + run: | + uv sync --frozen --all-extras + uv pip install mypy pytest pytest-asyncio pytest-cov black ruff + echo "✅ Pattern Stack environment ready (Python ${{ inputs.python-version }})" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1420e53..b8db3fe 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,8 @@ Desktop.ini *.swp *.swo *~.cli_patterns_history + +# CI artifacts +/tmp/ +benchmark_results.json +# .docker/ # Commented out to allow CI entrypoint script diff --git a/CLAUDE.md b/CLAUDE.md index cad3853..4943265 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,9 @@ PYTHONPATH=src python3 -m pytest tests/unit/ui/design/ -v # Test execution components PYTHONPATH=src python3 -m pytest tests/unit/execution/ -v + +# Test parser components +PYTHONPATH=src python3 -m pytest tests/unit/ui/parser/ -v ``` ## Architecture Overview @@ -60,6 +63,7 @@ src/cli_patterns/ ├── execution/ # Runtime engine and subprocess execution ├── ui/ # User interface components │ ├── design/ # Design system (themes, tokens, components) +│ ├── parser/ # Command parsing system │ └── screens/ # Screen implementations (future) └── cli.py # Main entry point ``` @@ -71,12 +75,20 @@ The UI uses a comprehensive design system with: - **Component Registry**: Centralized component registration and theming - **Rich Integration**: Built on Rich library for terminal rendering +### Parser System +The command parsing system provides flexible input interpretation: +- **Protocol-based**: All parsers implement the `Parser` protocol for consistency +- **Pipeline Architecture**: `ParserPipeline` routes input to appropriate parsers +- **Command Registry**: Manages command metadata and provides fuzzy-matching suggestions +- **Multiple Paradigms**: Supports text commands, shell pass-through (!), and extensible for more + ### Key Protocols - `WizardConfig`: Complete wizard definition - `SessionState`: Runtime state management - `ActionExecutor`: Protocol for action execution - `OptionCollector`: Protocol for option collection - `NavigationController`: Protocol for navigation +- `Parser`: Protocol for command parsers with consistent input interpretation ## Type System Requirements @@ -103,11 +115,13 @@ Key testing focus areas: ### Completed Components - Design system (themes, tokens, components, registry) -- Subprocess executor with async execution and themed output +- Subprocess executor with async execution and themed output (CLI-9) +- Interactive shell with prompt_toolkit (CLI-7) +- Command parser system with composable architecture (CLI-8) +- Command registry with fuzzy matching and suggestions - Basic type definitions and protocols structure -### In Progress (Branch CLI-9) -- Subprocess executor enhancements +### In Progress - Test coverage improvements - Integration testing @@ -115,7 +129,6 @@ Key testing focus areas: - Core wizard models and validation - YAML/JSON definition loaders - Python decorator system -- Interactive shell with prompt_toolkit - Navigation controller - Session state management - CLI entry point diff --git a/Makefile b/Makefile index e3e13fc..4f9e9aa 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # CLI Patterns Makefile # Development and testing automation -.PHONY: help install test test-unit test-integration test-coverage lint type-check format clean all +.PHONY: help install test test-unit test-integration test-coverage test-parser test-executor test-design test-fast test-components lint type-check format clean clean-docker all quality format-check ci-setup ci-native ci-docker verify-sync benchmark test-all ci-summary # Default target help: @@ -12,32 +12,59 @@ help: @echo "make test-unit - Run unit tests only" @echo "make test-integration - Run integration tests only" @echo "make test-coverage - Run tests with coverage report" + @echo "make test-parser - Run parser component tests" + @echo "make test-executor - Run executor/execution component tests" + @echo "make test-design - Run design system tests" + @echo "make test-fast - Run non-slow tests only" + @echo "make test-components - Run all component tests (parser, executor, design)" @echo "make lint - Run ruff linter" @echo "make type-check - Run mypy type checking" @echo "make format - Format code with black" @echo "make clean - Remove build artifacts and cache" + @echo "make clean-docker - Clean up Docker containers and volumes" @echo "make all - Run format, lint, type-check, and test" # Install dependencies install: - uv sync - uv add --dev mypy pytest pytest-asyncio pytest-cov pre-commit black ruff + @if command -v uv > /dev/null 2>&1; then \ + uv sync; \ + uv add --dev mypy pytest pytest-asyncio pytest-cov pre-commit black ruff; \ + else \ + pip install -e .; \ + pip install mypy pytest pytest-asyncio pytest-cov pre-commit black ruff; \ + fi # Run all tests test: - PYTHONPATH=src python3 -m pytest tests/ -v + @if command -v uv > /dev/null 2>&1; then \ + PYTHONPATH=src uv run pytest tests/ -v; \ + else \ + PYTHONPATH=src python3 -m pytest tests/ -v; \ + fi # Run unit tests only test-unit: - PYTHONPATH=src python3 -m pytest tests/unit/ -v + @if command -v uv > /dev/null 2>&1; then \ + PYTHONPATH=src uv run pytest tests/unit/ -v; \ + else \ + PYTHONPATH=src python3 -m pytest tests/unit/ -v; \ + fi # Run integration tests only test-integration: - PYTHONPATH=src python3 -m pytest tests/integration/ -v + @if command -v uv > /dev/null 2>&1; then \ + PYTHONPATH=src uv run pytest tests/integration/ -v; \ + else \ + PYTHONPATH=src python3 -m pytest tests/integration/ -v; \ + fi # Run tests with coverage test-coverage: - PYTHONPATH=src python3 -m pytest tests/ --cov=cli_patterns --cov-report=term-missing --cov-report=html + @if command -v uv > /dev/null 2>&1; then \ + PYTHONPATH=src uv run pytest tests/ --cov=cli_patterns --cov-report=term-missing --cov-report=html; \ + else \ + PYTHONPATH=src python3 -m pytest tests/ --cov=cli_patterns --cov-report=term-missing --cov-report=html; \ + fi # Run specific test file test-file: @@ -46,15 +73,27 @@ test-file: # Lint code lint: - uv run ruff check src/ tests/ + @if command -v uv > /dev/null 2>&1; then \ + uv run ruff check src/ tests/; \ + else \ + ruff check src/ tests/; \ + fi # Type check with mypy type-check: - PYTHONPATH=src python3 -m mypy src/cli_patterns --strict + @if command -v uv > /dev/null 2>&1; then \ + PYTHONPATH=src uv run mypy src/cli_patterns --strict; \ + else \ + PYTHONPATH=src python3 -m mypy src/cli_patterns --strict; \ + fi # Format code format: - uv run black src/ tests/ + @if command -v uv > /dev/null 2>&1; then \ + uv run black src/ tests/; \ + else \ + black src/ tests/; \ + fi # Clean build artifacts clean: @@ -66,16 +105,28 @@ clean: rm -rf .coverage rm -rf .ruff_cache +# Clean Docker containers and volumes +clean-docker: + docker compose -f docker-compose.ci.yml down --remove-orphans + # Run all quality checks all: format lint type-check test # Quick test for current work quick: - PYTHONPATH=src python3 -m pytest tests/unit/ui/design/ -v + @if command -v uv > /dev/null 2>&1; then \ + PYTHONPATH=src uv run pytest tests/unit/ui/design/ -v; \ + else \ + PYTHONPATH=src python3 -m pytest tests/unit/ui/design/ -v; \ + fi # Watch tests (requires pytest-watch) watch: - PYTHONPATH=src python3 -m pytest-watch tests/ --clear + @if command -v uv > /dev/null 2>&1; then \ + PYTHONPATH=src uv run pytest-watch tests/ --clear; \ + else \ + PYTHONPATH=src python3 -m pytest-watch tests/ --clear; \ + fi # Run pre-commit hooks pre-commit: @@ -85,6 +136,38 @@ pre-commit: pre-commit-install: pre-commit install +# Run tests by marker +test-parser: + @if command -v uv > /dev/null 2>&1; then \ + PYTHONPATH=src uv run pytest tests/ -m parser -v; \ + else \ + PYTHONPATH=src python3 -m pytest tests/ -m parser -v; \ + fi + +test-executor: + @if command -v uv > /dev/null 2>&1; then \ + PYTHONPATH=src uv run pytest tests/ -m executor -v; \ + else \ + PYTHONPATH=src python3 -m pytest tests/ -m executor -v; \ + fi + +test-design: + @if command -v uv > /dev/null 2>&1; then \ + PYTHONPATH=src uv run pytest tests/ -m design -v; \ + else \ + PYTHONPATH=src python3 -m pytest tests/ -m design -v; \ + fi + +test-fast: + @if command -v uv > /dev/null 2>&1; then \ + PYTHONPATH=src uv run pytest tests/ -m "not slow" -v; \ + else \ + PYTHONPATH=src python3 -m pytest tests/ -m "not slow" -v; \ + fi + +test-components: + PYTHONPATH=src python3 -m pytest tests/ -m "parser or executor or design" -v + # Show test summary summary: @echo "Test Summary" @@ -94,4 +177,61 @@ summary: @echo -n "Integration Tests: " @PYTHONPATH=src python3 -m pytest tests/integration/ -q 2>/dev/null | tail -1 @echo -n "Type Check: " - @PYTHONPATH=src python3 -m mypy src/cli_patterns --strict 2>&1 | grep -E "Success|Found" | head -1 \ No newline at end of file + @PYTHONPATH=src python3 -m mypy src/cli_patterns --strict 2>&1 | grep -E "Success|Found" | head -1 + +# CI-specific targets +# Combined quality checks +quality: lint type-check format-check + +# Format check (for CI, doesn't modify) +format-check: + @if command -v uv > /dev/null 2>&1; then \ + uv run black src/ tests/ --check; \ + else \ + black src/ tests/ --check; \ + fi + +# Environment info (for sync checking) +ci-setup: + @echo "=== Environment Info ===" + @python3 --version + @if command -v uv > /dev/null 2>&1; then \ + uv --version; \ + echo "=== Dependencies (first 10) ==="; \ + uv pip list | head -10; \ + else \ + pip --version; \ + echo "=== Dependencies (first 10) ==="; \ + pip list | head -10; \ + fi + +# Native CI run +ci-native: quality test-all + +# Docker CI run +ci-docker: + docker compose -f docker-compose.ci.yml run --rm ci make ci-native + +# Verify environments are in sync +verify-sync: + @echo "Checking native environment..." + @make ci-setup > /tmp/native-env.txt + @echo "Checking Docker environment..." + @docker compose -f docker-compose.ci.yml run ci make ci-setup > /tmp/docker-env.txt + @echo "Comparing..." + @diff /tmp/native-env.txt /tmp/docker-env.txt && echo "✅ In sync!" || echo "❌ Out of sync!" + +# Placeholder for future benchmarks +benchmark: + @echo "Benchmark suite not yet implemented" + @echo "Future: pytest tests/ --benchmark-only" + +# All tests +test-all: test-unit test-integration + +# Summary for CI +ci-summary: + @echo "=== CI Summary ===" + @echo "Quality checks: make quality" + @echo "All tests: make test-all" + @echo "Component tests: make test-components" \ No newline at end of file diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 0000000..6eac13f --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,26 @@ +services: + ci: + image: python:3.9-slim-bookworm + environment: + - PYTHONPATH=src + - CI=true + volumes: + - .:/workspace + - pip-cache:/root/.cache/pip + working_dir: /workspace + entrypoint: ["/workspace/.docker/ci-entrypoint.sh"] + + # For performance regression testing (future) + benchmark: + extends: ci + cpus: '2.0' + mem_limit: 4g + memswap_limit: 4g + environment: + - PYTHONPATH=src + - CI=true + - BENCHMARK_MODE=true + +volumes: + pip-cache: + driver: local \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ad70c3b..9d914dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,16 @@ addopts = "-ra -q" testpaths = [ "tests", ] +markers = [ + "unit: Unit tests (tests/unit/)", + "integration: Integration tests (tests/integration/)", + "slow: Tests taking longer than 0.5 seconds", + "parser: Parser component tests", + "executor: Executor/execution component tests", + "design: Design system tests", + "ui: UI component tests", + "asyncio: Async tests (already in use)" +] [dependency-groups] dev = [ diff --git a/src/cli_patterns/ui/parser/protocols.py b/src/cli_patterns/ui/parser/protocols.py index 2501751..058a9a4 100644 --- a/src/cli_patterns/ui/parser/protocols.py +++ b/src/cli_patterns/ui/parser/protocols.py @@ -54,7 +54,3 @@ def get_suggestions(self, partial: str) -> list[str]: List of suggested completions for the partial input """ ... - - -# Explicitly set the runtime checkable attribute for older Python versions -Parser.__runtime_checkable__ = True diff --git a/src/cli_patterns/ui/parser/registry.py b/src/cli_patterns/ui/parser/registry.py index 566e42d..d8c2c99 100644 --- a/src/cli_patterns/ui/parser/registry.py +++ b/src/cli_patterns/ui/parser/registry.py @@ -4,6 +4,7 @@ import difflib from dataclasses import dataclass, field +from functools import lru_cache from typing import Any, Callable, Optional # No longer need ParseError import for registry validation @@ -48,12 +49,26 @@ class CommandRegistry: and intelligent suggestions for typos and partial matches. """ - def __init__(self) -> None: - """Initialize empty command registry.""" + def __init__(self, cache_size: int = 128) -> None: + """Initialize empty command registry. + + Args: + cache_size: Maximum number of entries to cache for suggestion lookups. + Set to 0 to disable caching. + """ self._commands: dict[str, CommandMetadata] = {} self._aliases: dict[str, str] = {} # alias -> command_name mapping self._categories: set[str] = set() + # Create cached version of suggestion computation if caching is enabled + if cache_size > 0: + self._cached_suggestions: Callable[[str, int], list[str]] = lru_cache( + maxsize=cache_size + )(self._compute_suggestions) + else: + # No caching - use direct computation + self._cached_suggestions = self._compute_suggestions + def register(self, metadata: CommandMetadata) -> None: """Register a command with the registry. @@ -93,6 +108,12 @@ def register(self, metadata: CommandMetadata) -> None: # Track category self._categories.add(metadata.category) + # Clear suggestion cache since command set has changed + if hasattr(self, "_cached_suggestions") and hasattr( + self._cached_suggestions, "cache_clear" + ): + self._cached_suggestions.cache_clear() + def register_command(self, metadata: CommandMetadata) -> None: """Register a command (alias for register method). @@ -128,6 +149,12 @@ def unregister(self, name: str) -> bool: ): self._categories.discard(metadata.category) + # Clear suggestion cache since command set has changed + if hasattr(self, "_cached_suggestions") and hasattr( + self._cached_suggestions, "cache_clear" + ): + self._cached_suggestions.cache_clear() + return True def get(self, name: str) -> Optional[CommandMetadata]: @@ -210,6 +237,24 @@ def get_categories(self) -> list[str]: def get_suggestions(self, partial: str, limit: int = 5) -> list[str]: """Get command name suggestions based on partial input. + Uses an LRU cache to improve performance for repeated lookups. + Cache is automatically cleared when commands are registered/unregistered. + + Args: + partial: Partial command name + limit: Maximum number of suggestions to return + + Returns: + List of suggested command names + """ + return self._cached_suggestions(partial, limit) + + def _compute_suggestions(self, partial: str, limit: int) -> list[str]: + """Compute command name suggestions based on partial input. + + This is the internal method that performs the actual suggestion computation. + It is wrapped with an LRU cache for performance. + Args: partial: Partial command name limit: Maximum number of suggestions to return diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cb31a91 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +"""Test configuration and auto-marking for pytest.""" + +import pytest + + +def pytest_collection_modifyitems(config, items): + """Auto-mark tests based on their location.""" + for item in items: + # Path-based markers + if "tests/unit" in str(item.fspath): + item.add_marker(pytest.mark.unit) + elif "tests/integration" in str(item.fspath): + item.add_marker(pytest.mark.integration) + + # Component-based markers + if "/parser/" in str(item.fspath): + item.add_marker(pytest.mark.parser) + elif "/execution/" in str(item.fspath) or "subprocess" in item.name: + item.add_marker(pytest.mark.executor) + elif "/design/" in str(item.fspath): + item.add_marker(pytest.mark.design) + elif "/ui/" in str(item.fspath): + item.add_marker(pytest.mark.ui) diff --git a/tests/integration/test_design_system.py b/tests/integration/test_design_system.py index 04e3a8e..040508c 100644 --- a/tests/integration/test_design_system.py +++ b/tests/integration/test_design_system.py @@ -4,11 +4,10 @@ import tempfile from pathlib import Path +import pytest import yaml -from cli_patterns.config.theme_loader import ( - load_theme_from_yaml, -) +from cli_patterns.config.theme_loader import load_theme_from_yaml from cli_patterns.ui.design.boxes import BOX_STYLES, BoxStyle from cli_patterns.ui.design.components import Output, Panel, ProgressBar, Prompt from cli_patterns.ui.design.icons import get_icon_set @@ -22,6 +21,8 @@ StatusToken, ) +pytestmark = pytest.mark.design + class TestDesignSystemIntegration: """Test that all design system components work together.""" diff --git a/tests/integration/test_shell_parser_integration.py b/tests/integration/test_shell_parser_integration.py index ad579c2..0bca200 100644 --- a/tests/integration/test_shell_parser_integration.py +++ b/tests/integration/test_shell_parser_integration.py @@ -23,6 +23,8 @@ # Import shell and parser components from cli_patterns.ui.shell import InteractiveShell +pytestmark = pytest.mark.parser + class TestShellParserIntegration: """Integration tests for shell and parser system working together.""" diff --git a/tests/integration/test_subprocess_executor.py b/tests/integration/test_subprocess_executor.py index b5d48fc..b2c2ec2 100644 --- a/tests/integration/test_subprocess_executor.py +++ b/tests/integration/test_subprocess_executor.py @@ -13,6 +13,8 @@ from cli_patterns.execution.subprocess_executor import SubprocessExecutor from cli_patterns.ui.design.registry import theme_registry +pytestmark = pytest.mark.executor + class TestSubprocessExecutorIntegration: """Integration tests for SubprocessExecutor with real commands.""" @@ -98,6 +100,7 @@ async def test_exit_codes(self, executor): assert result.exit_code == 42 @pytest.mark.asyncio + @pytest.mark.slow async def test_timeout(self, executor): """Test command timeout.""" # Use Python sleep to ensure cross-platform compatibility @@ -135,6 +138,7 @@ async def test_environment_variables(self, executor): assert "test_value" in result.stdout @pytest.mark.asyncio + @pytest.mark.slow async def test_large_output(self, executor): """Test command with large output.""" # Generate 1000 lines of output @@ -149,6 +153,7 @@ async def test_large_output(self, executor): assert lines[-1] == "Line 999" @pytest.mark.asyncio + @pytest.mark.slow async def test_concurrent_execution(self, executor): """Test running multiple commands concurrently.""" tasks = [ @@ -165,6 +170,7 @@ async def test_concurrent_execution(self, executor): assert "Command 3" in results[2].stdout @pytest.mark.asyncio + @pytest.mark.slow async def test_streaming_output(self): """Test output streaming in real-time.""" console = Console(force_terminal=True, width=80, no_color=True) diff --git a/tests/unit/execution/test_subprocess_executor.py b/tests/unit/execution/test_subprocess_executor.py index 4022337..2cbc513 100644 --- a/tests/unit/execution/test_subprocess_executor.py +++ b/tests/unit/execution/test_subprocess_executor.py @@ -10,6 +10,8 @@ from cli_patterns.execution.subprocess_executor import CommandResult, SubprocessExecutor +pytestmark = pytest.mark.executor + class TestCommandResult: """Test CommandResult class.""" @@ -138,6 +140,7 @@ async def test_permission_denied(self, executor, console): assert console.print.called @pytest.mark.asyncio + @pytest.mark.slow async def test_timeout(self, executor, console): """Test command timeout.""" with patch("asyncio.create_subprocess_shell") as mock_create: diff --git a/tests/unit/ui/design/test_components.py b/tests/unit/ui/design/test_components.py index 93890f9..7960d7e 100644 --- a/tests/unit/ui/design/test_components.py +++ b/tests/unit/ui/design/test_components.py @@ -1,5 +1,7 @@ """Tests for UI components and design elements.""" +import pytest + from cli_patterns.ui.design.boxes import ( ASCII, BOX_STYLES, @@ -23,6 +25,8 @@ StatusToken, ) +pytestmark = pytest.mark.design + class TestPanelComponent: """Tests for Panel component default values.""" diff --git a/tests/unit/ui/design/test_themes.py b/tests/unit/ui/design/test_themes.py index 4b2cd4d..18bf829 100644 --- a/tests/unit/ui/design/test_themes.py +++ b/tests/unit/ui/design/test_themes.py @@ -25,6 +25,8 @@ StatusToken, ) +pytestmark = pytest.mark.design + class TestTheme: """Tests for the base Theme class.""" diff --git a/tests/unit/ui/design/test_tokens.py b/tests/unit/ui/design/test_tokens.py index b0f070d..8915597 100644 --- a/tests/unit/ui/design/test_tokens.py +++ b/tests/unit/ui/design/test_tokens.py @@ -13,6 +13,8 @@ StatusToken, ) +pytestmark = pytest.mark.design + class TestCategoryToken: """Test CategoryToken enum.""" diff --git a/tests/unit/ui/parser/test_pipeline.py b/tests/unit/ui/parser/test_pipeline.py index 5ba5ec4..7425e22 100644 --- a/tests/unit/ui/parser/test_pipeline.py +++ b/tests/unit/ui/parser/test_pipeline.py @@ -10,6 +10,8 @@ from cli_patterns.ui.parser.protocols import Parser from cli_patterns.ui.parser.types import Context, ParseError, ParseResult +pytestmark = pytest.mark.parser + class TestParserPipeline: """Test ParserPipeline basic functionality.""" @@ -612,7 +614,8 @@ def test_dynamic_parser_selection(self, pipeline: ParserPipeline) -> None: and input.strip().endswith(">"), ) pipeline.add_parser( - text_parser, lambda input, ctx: True # Fallback for plain text + text_parser, + lambda input, ctx: True, # Fallback for plain text ) context = Context("interactive", [], {}) diff --git a/tests/unit/ui/parser/test_protocols.py b/tests/unit/ui/parser/test_protocols.py index 449ab5c..a613ec7 100644 --- a/tests/unit/ui/parser/test_protocols.py +++ b/tests/unit/ui/parser/test_protocols.py @@ -10,13 +10,35 @@ from cli_patterns.ui.parser.protocols import Parser from cli_patterns.ui.parser.types import Context, ParseResult +pytestmark = pytest.mark.parser + class TestParserProtocol: """Test Parser protocol definition and behavior.""" def test_parser_is_runtime_checkable(self) -> None: - """Test that Parser protocol is runtime checkable.""" - assert hasattr(Parser, "__runtime_checkable__") + """Test that Parser protocol supports isinstance checks.""" + + # Test the actual functionality: isinstance checking should work + class ValidImplementation: + def can_parse(self, input: str, context: Context) -> bool: + return True + + def parse(self, input: str, context: Context) -> ParseResult: + return ParseResult("test", [], set(), {}, input) + + def get_suggestions(self, partial: str) -> list[str]: + return [] + + class InvalidImplementation: + pass + + valid = ValidImplementation() + invalid = InvalidImplementation() + + # This is what @runtime_checkable actually enables + assert isinstance(valid, Parser) + assert not isinstance(invalid, Parser) def test_parser_protocol_methods(self) -> None: """Test that Parser protocol has required methods.""" @@ -451,8 +473,19 @@ def test_protocol_typing_information(self) -> None: # Should be identifiable as a Protocol assert issubclass(Parser, Protocol) - # Should have runtime checkable decorator - assert getattr(Parser, "__runtime_checkable__", False) + # Should support runtime type checking (the actual purpose of @runtime_checkable) + class TestImplementation: + def can_parse(self, input: str, context: Context) -> bool: + return True + + def parse(self, input: str, context: Context) -> ParseResult: + return ParseResult("test", [], set(), {}, input) + + def get_suggestions(self, partial: str) -> list[str]: + return [] + + impl = TestImplementation() + assert isinstance(impl, Parser) # This is what matters, not internal attributes class TestParserProtocolEdgeCases: diff --git a/tests/unit/ui/parser/test_registry.py b/tests/unit/ui/parser/test_registry.py index 7ae0e8c..d4f3c09 100644 --- a/tests/unit/ui/parser/test_registry.py +++ b/tests/unit/ui/parser/test_registry.py @@ -6,6 +6,8 @@ from cli_patterns.ui.parser.registry import CommandMetadata, CommandRegistry +pytestmark = pytest.mark.parser + class TestCommandMetadata: """Test CommandMetadata dataclass.""" @@ -741,3 +743,184 @@ def test_thread_safety_considerations(self) -> None: commands = registry.list_commands() assert len(commands) == 1 assert commands[0].name == "test" + + +class TestCommandRegistryCache: + """Test caching functionality for command suggestions.""" + + @pytest.fixture + def registry(self) -> CommandRegistry: + """Create registry with caching enabled.""" + return CommandRegistry(cache_size=16) # Small cache for testing + + @pytest.fixture + def populated_registry(self) -> CommandRegistry: + """Create registry with commands for testing caching.""" + registry = CommandRegistry(cache_size=16) + + commands = [ + CommandMetadata("help", "Show help", ["h"]), + CommandMetadata("history", "Show history", ["hist"]), + CommandMetadata("halt", "Stop system"), + CommandMetadata("list", "List items", ["ls"]), + CommandMetadata("login", "User login"), + CommandMetadata("logout", "User logout"), + CommandMetadata("status", "Show status", ["stat"]), + CommandMetadata("start", "Start service"), + CommandMetadata("stop", "Stop service"), + ] + + for cmd in commands: + registry.register_command(cmd) + + return registry + + def test_cache_enabled_by_default(self) -> None: + """Test that caching is enabled by default.""" + registry = CommandRegistry() + # Should have the cached suggestions method + assert hasattr(registry, "_cached_suggestions") + assert hasattr(registry._cached_suggestions, "cache_clear") + + def test_cache_disabled_when_size_zero(self) -> None: + """Test that caching is disabled when cache_size is 0.""" + registry = CommandRegistry(cache_size=0) + # Should not have cache_clear method when caching is disabled + assert hasattr(registry, "_cached_suggestions") + assert not hasattr(registry._cached_suggestions, "cache_clear") + + def test_suggestions_are_cached(self, populated_registry: CommandRegistry) -> None: + """Test that suggestions are cached and return same results.""" + # First call + suggestions1 = populated_registry.get_suggestions("hel", limit=3) + + # Second call should return same results (from cache) + suggestions2 = populated_registry.get_suggestions("hel", limit=3) + + assert suggestions1 == suggestions2 + assert "help" in suggestions1 + + def test_cache_invalidated_on_register(self, registry: CommandRegistry) -> None: + """Test that cache is cleared when registering new commands.""" + # Register initial command + cmd1 = CommandMetadata("help", "Show help") + registry.register_command(cmd1) + + # Get suggestions to populate cache + suggestions1 = registry.get_suggestions("h", limit=5) + assert "help" in suggestions1 + + # Register new command that would affect suggestions + cmd2 = CommandMetadata("hello", "Say hello") + registry.register_command(cmd2) + + # Get suggestions again - should include new command + suggestions2 = registry.get_suggestions("h", limit=5) + assert "help" in suggestions2 + assert "hello" in suggestions2 + + def test_cache_invalidated_on_unregister( + self, populated_registry: CommandRegistry + ) -> None: + """Test that cache is cleared when unregistering commands.""" + # Get initial suggestions to populate cache + suggestions1 = populated_registry.get_suggestions("hel", limit=5) + assert "help" in suggestions1 + + # Unregister a command + result = populated_registry.unregister("help") + assert result is True + + # Get suggestions again - should not include unregistered command + suggestions2 = populated_registry.get_suggestions("hel", limit=5) + assert "help" not in suggestions2 + + def test_cache_with_different_parameters( + self, populated_registry: CommandRegistry + ) -> None: + """Test that cache works correctly with different parameter combinations.""" + # Different partial strings should cache separately + suggestions_h = populated_registry.get_suggestions("h", limit=3) + suggestions_s = populated_registry.get_suggestions("s", limit=3) + suggestions_l = populated_registry.get_suggestions("l", limit=3) + + # Each should return appropriate results + assert any( + "help" in s or "history" in s or "halt" in s for s in [suggestions_h] + ) + assert any( + "status" in s or "start" in s or "stop" in s for s in [suggestions_s] + ) + assert any( + "list" in s or "login" in s or "logout" in s for s in [suggestions_l] + ) + + # Different limits for same partial should cache separately + suggestions_h_3 = populated_registry.get_suggestions("h", limit=3) + suggestions_h_5 = populated_registry.get_suggestions("h", limit=5) + + # Results should be consistent within same parameters + assert suggestions_h == suggestions_h_3 + # But different limits might return different number of results + assert len(suggestions_h_5) >= len(suggestions_h_3) + + def test_cache_performance_improvement( + self, populated_registry: CommandRegistry + ) -> None: + """Test that caching provides performance benefit (basic test).""" + import time + + # First call (cold cache) + start_time = time.time() + suggestions1 = populated_registry.get_suggestions("h", limit=10) + first_call_time = time.time() - start_time + + # Second call (warm cache) - should be faster + start_time = time.time() + suggestions2 = populated_registry.get_suggestions("h", limit=10) + second_call_time = time.time() - start_time + + # Results should be the same + assert suggestions1 == suggestions2 + + # Second call should generally be faster (though this can be flaky in tests) + # We just verify it's not significantly slower + assert second_call_time <= first_call_time * 2 # Allow some variance + + def test_cache_with_empty_partial( + self, populated_registry: CommandRegistry + ) -> None: + """Test that empty partial input is cached correctly.""" + # Empty string should return common commands + suggestions1 = populated_registry.get_suggestions("", limit=5) + suggestions2 = populated_registry.get_suggestions("", limit=5) + + assert suggestions1 == suggestions2 + assert isinstance(suggestions1, list) + + def test_cache_size_parameter(self) -> None: + """Test different cache sizes.""" + # Small cache + small_registry = CommandRegistry(cache_size=2) + assert hasattr(small_registry._cached_suggestions, "cache_clear") + + # Large cache + large_registry = CommandRegistry(cache_size=1000) + assert hasattr(large_registry._cached_suggestions, "cache_clear") + + # Zero cache (disabled) + no_cache_registry = CommandRegistry(cache_size=0) + assert not hasattr(no_cache_registry._cached_suggestions, "cache_clear") + + def test_cache_with_aliases(self, registry: CommandRegistry) -> None: + """Test that caching works correctly with aliases.""" + # Register command with aliases + cmd = CommandMetadata("list", "List items", ["ls", "l"]) + registry.register_command(cmd) + + # Get suggestions that should include aliases + suggestions1 = registry.get_suggestions("l", limit=5) + suggestions2 = registry.get_suggestions("l", limit=5) + + assert suggestions1 == suggestions2 + assert any(s in ["list", "ls", "l"] for s in suggestions1) diff --git a/tests/unit/ui/parser/test_shell_parser.py b/tests/unit/ui/parser/test_shell_parser.py index b80e702..8512b2a 100644 --- a/tests/unit/ui/parser/test_shell_parser.py +++ b/tests/unit/ui/parser/test_shell_parser.py @@ -7,6 +7,8 @@ from cli_patterns.ui.parser.parsers import ShellParser from cli_patterns.ui.parser.types import Context, ParseError, ParseResult +pytestmark = pytest.mark.parser + class TestShellParserBasics: """Test basic ShellParser functionality.""" diff --git a/tests/unit/ui/parser/test_text_parser.py b/tests/unit/ui/parser/test_text_parser.py index 1ecefa2..61d7e49 100644 --- a/tests/unit/ui/parser/test_text_parser.py +++ b/tests/unit/ui/parser/test_text_parser.py @@ -7,6 +7,8 @@ from cli_patterns.ui.parser.parsers import TextParser from cli_patterns.ui.parser.types import Context, ParseError, ParseResult +pytestmark = pytest.mark.parser + class TestTextParserBasics: """Test basic TextParser functionality.""" diff --git a/tests/unit/ui/parser/test_types.py b/tests/unit/ui/parser/test_types.py index 29465ed..a48093e 100644 --- a/tests/unit/ui/parser/test_types.py +++ b/tests/unit/ui/parser/test_types.py @@ -13,6 +13,8 @@ ParseResult, ) +pytestmark = pytest.mark.parser + class TestParseResult: """Test ParseResult dataclass."""