diff --git a/.gitignore b/.gitignore index 6f72f892..86160193 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,41 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll +# Python +__pycache__/ +*.py[cod] +*$py.class *.so -*.dylib +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg -# Test binary, built with `go test -c` -*.test +# Virtual Environment +venv/ +env/ +ENV/ -# Output of the go coverage tool, specifically when used with LiteIDE -*.out +# IDE +.idea/ +.vscode/ +*.swp +*.swo -# Dependency directories (remove the comment below to include it) -# vendor/ +# Testing +.coverage +coverage.xml +htmlcov/ +.pytest_cache/ -# Go workspace file -go.work -go.work.sum - -# env file -.env +# Misc +.DS_Store diff --git a/README.md b/README.md index 4dc691de..d9df65dc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,13 @@ -# CodeGate +# Codegate + +A configurable Generative AI gateway, protecting developers from the dangers of AI. + +## Features + +- Secrets exflitration prevention +- Secure Coding recommendations +- Preventing AI from recommending deprecated and / or malicious libraries + ### Installation @@ -30,5 +39,70 @@ VSCode editor. ![Continue Chat](./static/image.png) +## Usage + +### Basic Usage (Manual) + +Start the server with default settings: + +```bash +codegate serve +``` + +### Custom Configuration + +Start with custom settings: + +```bash +codegate serve --port 8989 --host localhost --log-level DEBUG +``` + +### Configuration File + +Use a YAML configuration file: + +```bash +codegate serve --config my_config.yaml +``` + +Example `config.yaml`: + +```yaml +port: 8989 +host: "localhost" +log_level: "DEBUG" +``` + +### Environment Variables + +Configure using environment variables: + +```bash +export CODEGATE_APP_PORT=8989 +export CODEGATE_APP_HOST=localhost +export CODEGATE_APP_LOG_LEVEL=DEBUG +codegate serve +``` + +## Development + +### Setup +```bash +# Clone the repository +git clone https://github.com/stacklok/codegate.git +cd codegate +# Create and activate a virtual environment +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install development dependencies +pip install -e ".[dev]" +``` + +### Testing + +```bash +pytest +``` diff --git a/config.yaml b/config.yaml new file mode 100644 index 00000000..113b6aa4 --- /dev/null +++ b/config.yaml @@ -0,0 +1,12 @@ +# Codegate Example Configuration + +# Network settings +port: 8989 # Port to listen on (1-65535) +host: "localhost" # Host to bind to (use localhost for all interfaces) + +# Logging configuration +log_level: "INFO" # One of: ERROR, WARNING, INFO, DEBUG + +# Note: This configuration can be overridden by: +# 1. CLI arguments (--port, --host, --log-level) +# 2. Environment variables (CODEGATE_APP_PORT, CODEGATE_APP_HOST, CODEGATE_APP_LOG_LEVEL) diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 00000000..8936cfbd --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,17 @@ +# Codegate Example Configuration + +# Network settings +port: 8989 # Port to listen on (1-65535) +host: "localhost" # Host to bind to (use localhost for all interfaces) + +# Logging configuration +log_level: "INFO" # One of: ERROR, WARNING, INFO, DEBUG +log_format: "JSON" # One of: JSON, TEXT + +# Note: This configuration can be overridden by: +# 1. CLI arguments (--port, --host, --log-level, --log-format) +# 2. Environment variables: +# - CODEGATE_APP_PORT +# - CODEGATE_APP_HOST +# - CODEGATE_APP_LOG_LEVEL +# - CODEGATE_LOG_FORMAT diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..965b8b8a --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,74 @@ +# CLI Commands and Flags + +Codegate provides a command-line interface through `cli.py` with the following +structure: + +## Main Command + +```bash +codegate [OPTIONS] COMMAND [ARGS]... +``` + +## Available Commands + +### serve + +Start the Codegate server: + +```bash +codegate serve [OPTIONS] +``` + +#### Options + +- `--port INTEGER`: Port to listen on (default: 8989) + - Must be between 1 and 65535 + - Overrides configuration file and environment variables + +- `--host TEXT`: Host to bind to (default: localhost) + - Overrides configuration file and environment variables + +- `--log-level [ERROR|WARNING|INFO|DEBUG]`: Set the log level (default: INFO) + - Case-insensitive + - Overrides configuration file and environment variables + +- `--log-format [JSON|TEXT]`: Set the log format (default: JSON) + - Case-insensitive + - Overrides configuration file and environment variables + +- `--config FILE`: Path to YAML config file + - Optional + - Must be a valid YAML file + - Configuration values can be overridden by environment variables and CLI options + +## Error Handling + +The CLI provides user-friendly error messages for: +- Invalid port numbers +- Invalid log levels +- Invalid log formats +- Configuration file errors +- Server startup failures + +All errors are output to stderr with appropriate exit codes. + +## Examples + +Start server with default settings: +```bash +codegate serve +``` + +Start server on specific port and host: +```bash +codegate serve --port 8989 --host localhost +``` + +Start server with custom logging: +```bash +codegate serve --log-level DEBUG --log-format TEXT +``` + +Start server with configuration file: +```bash +codegate serve --config my-config.yaml diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..3e0ffbf3 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,68 @@ +# Configuration System + +The configuration system in Codegate is managed through the `Config` class in `config.py`. It supports multiple configuration sources with a clear priority order. + +## Configuration Priority (highest to lowest) + +1. CLI arguments +2. Environment variables +3. Config file (YAML) +4. Default values + +## Default Configuration Values + +- Port: 8989 +- Host: "localhost" +- Log Level: "INFO" +- Log Format: "JSON" + +## Configuration Methods + +### From File + +Load configuration from a YAML file: + +```python +config = Config.from_file("config.yaml") +``` + +### From Environment Variables + +Environment variables are automatically loaded with these mappings: + +- `CODEGATE_APP_PORT`: Server port +- `CODEGATE_APP_HOST`: Server host +- `CODEGATE_APP_LOG_LEVEL`: Logging level +- `CODEGATE_LOG_FORMAT`: Log format + +```python +config = Config.from_env() +``` + +## Configuration Options + +### Log Levels + +Available log levels (case-insensitive): + +- `ERROR` +- `WARNING` +- `INFO` +- `DEBUG` + +### Log Formats + +Available log formats (case-insensitive): + +- `JSON` +- `TEXT` + +## Error Handling + +The configuration system uses a custom `ConfigurationError` exception for handling configuration-related errors, such as: + +- Invalid port numbers (must be between 1 and 65535) +- Invalid log levels +- Invalid log formats +- YAML parsing errors +- File reading errors diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 00000000..e1e54e80 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,123 @@ +# Logging System + +The logging system in Codegate (`logging.py`) provides a flexible and structured logging solution with support for both JSON and text formats. + +## Log Routing + +Logs are automatically routed based on their level: + +- **stdout**: INFO and DEBUG messages +- **stderr**: ERROR, CRITICAL, and WARNING messages + +## Log Formats + +### JSON Format + +When using JSON format (default), log entries include: + +```json +{ + "timestamp": "YYYY-MM-DDThh:mm:ss.mmmZ", + "level": "LOG_LEVEL", + "module": "MODULE_NAME", + "message": "Log message", + "extra": { + // Additional fields as you desire + } +} +``` + +### Text Format + +When using text format, log entries follow this pattern: + +``` +YYYY-MM-DDThh:mm:ss.mmmZ - LEVEL - NAME - MESSAGE +``` + +## Features + +- **Consistent Timestamps**: ISO-8601 format with millisecond precision in UTC +- **Automatic JSON Serialization**: Extra fields are automatically serialized to JSON +- **Non-serializable Handling**: Graceful handling of non-serializable values +- **Exception Support**: Full exception and stack trace integration +- **Dual Output**: Separate handlers for error and non-error logs +- **Configurable Levels**: Support for ERROR, WARNING, INFO, and DEBUG levels + +## Usage Examples + +### Basic Logging + +```python +import logging + +logger = logging.getLogger(__name__) + +# Different log levels +logger.info("This is an info message") +logger.debug("This is a debug message") +logger.error("This is an error message") +logger.warning("This is a warning message") +``` + +### Logging with Extra Fields + +```python +logger.info("Server starting", extra={ + "host": "0", + "port": 8989, + "environment": "production" +}) +``` + +### Exception Logging + +```python +try: + # Some code that might raise an exception + raise ValueError("Something went wrong") +except Exception as e: + logger.error("Error occurred", exc_info=True) +``` + +## Configuration + +The logging system can be configured through: + +1. CLI arguments: + ```bash + codegate serve --log-level DEBUG --log-format TEXT + ``` + +2. Environment variables: + ```bash + export APP_LOG_LEVEL=DEBUG + export CODEGATE_LOG_FORMAT=TEXT + ``` + +3. Configuration file: + ```yaml + log_level: DEBUG + log_format: TEXT + ``` + +## Best Practices + +1. Always use the appropriate log level: + - ERROR: For errors that need immediate attention + - WARNING: For potentially harmful situations + - INFO: For general operational information + - DEBUG: For detailed information useful during development + +2. Include relevant context in extra fields: + ```python + logger.info("User action", extra={ + "user_id": "123", + "action": "login", + "ip_address": "192.168.1.1" + }) + ``` + +3. Use structured logging with JSON format in production for better log aggregation and analysis + +4. Enable DEBUG level logging during development for maximum visibility diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..df71b4b8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "codegate" +version = "0.1.0" +description = "Generative AI CodeGen security gateway" +readme = "README.md" +dependencies = [ + "click>=8.1.0", + "PyYAML>=6.0.1", + "fastapi>=0.115.5", + "uvicorn>=0.32.1", +] +requires-python = ">=3.10" + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-cov>=4.1.0", + "black>=23.7.0", + "ruff>=0.0.284", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.black] +line-length = 88 +target-version = ["py310"] + +[tool.ruff] +select = ["E", "F", "I", "N", "W"] +line-length = 88 +target-version = "py310" +fix = true + +[project.scripts] +codegate = "codegate.cli:main" + +[tool.pytest.ini_options] +addopts = "-v --cov=codegate --cov-report=term-missing" +testpaths = ["tests"] diff --git a/src/codegate/__init__.py b/src/codegate/__init__.py new file mode 100644 index 00000000..8635637a --- /dev/null +++ b/src/codegate/__init__.py @@ -0,0 +1,13 @@ +"""Codegate - A Generative AI security gateway.""" + +from importlib import metadata + +try: + __version__ = metadata.version("codegate") +except metadata.PackageNotFoundError: # pragma: no cover + __version__ = "unknown" + +from .config import Config, ConfigurationError +from .logging import setup_logging + +__all__ = ["Config", "ConfigurationError", "setup_logging"] diff --git a/src/codegate/cli.py b/src/codegate/cli.py new file mode 100644 index 00000000..a32c78d2 --- /dev/null +++ b/src/codegate/cli.py @@ -0,0 +1,112 @@ +"""Command-line interface for codegate.""" + +import logging +import sys +from pathlib import Path +from typing import Optional + +import click + +from .config import Config, ConfigurationError, LogLevel, LogFormat +from .logging import setup_logging + + +def validate_port(ctx: click.Context, param: click.Parameter, value: int) -> int: + """Validate the port number is in valid range.""" + if value is not None and not (1 <= value <= 65535): + raise click.BadParameter("Port must be between 1 and 65535") + return value + + +@click.group() +@click.version_option() +def cli() -> None: + """Codegate - A configurable service gateway.""" + pass + +@cli.command() +@click.option( + "--port", + type=int, + default=None, + callback=validate_port, + help="Port to listen on (default: 8989)", +) +@click.option( + "--host", + type=str, + default=None, + help="Host to bind to (default: localhost)", +) +@click.option( + "--log-level", + type=click.Choice([level.value for level in LogLevel]), + default=None, + help="Set the log level (default: INFO)", +) +@click.option( + "--log-format", + type=click.Choice([fmt.value for fmt in LogFormat], case_sensitive=False), + default=None, + help="Set the log format (default: JSON)", +) +@click.option( + "--config", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + default=None, + help="Path to YAML config file", +) +def serve( + port: Optional[int], + host: Optional[str], + log_level: Optional[str], + log_format: Optional[str], + config: Optional[Path], +) -> None: + """Start the codegate server.""" + + try: + # Load configuration with priority resolution + cfg = Config.load( + config_path=config, + cli_port=port, + cli_host=host, + cli_log_level=log_level, + cli_log_format=log_format, + ) + + setup_logging(cfg.log_level, cfg.log_format) + logger = logging.getLogger(__name__) + + logger.info("This is an info message") + logger.debug("This is a debug message") + logger.error("This is an error message") + logger.warning("This is a warning message") + + logger.info( + "Starting server", + extra={ + "host": cfg.host, + "port": cfg.port, + "log_level": cfg.log_level.value, + "log_format": cfg.log_format.value, + } + ) + + # TODO: Jakub Implement actual server logic here + logger.info("Server started successfully") + + except ConfigurationError as e: + click.echo(f"Configuration error: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + +def main() -> None: + """Main entry point for the CLI.""" + cli() + + +if __name__ == "__main__": + main() diff --git a/src/codegate/config.py b/src/codegate/config.py new file mode 100644 index 00000000..fba1bb22 --- /dev/null +++ b/src/codegate/config.py @@ -0,0 +1,199 @@ +"""Configuration management for codegate.""" + +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Optional, Union +import os +import yaml + + +class LogLevel(str, Enum): + """Valid log levels.""" + + ERROR = "ERROR" + WARNING = "WARNING" + INFO = "INFO" + DEBUG = "DEBUG" + + @classmethod + def _missing_(cls, value: str) -> Optional['LogLevel']: + """Handle case-insensitive lookup of enum values.""" + try: + # Convert to uppercase and look up directly + return cls[value.upper()] + except (KeyError, AttributeError): + raise ValueError( + f"'{value}' is not a valid LogLevel. " + f"Valid levels are: {', '.join(level.value for level in cls)}" + ) + +class LogFormat(str, Enum): + """Valid log formats.""" + + JSON = "JSON" + TEXT = "TEXT" + + @classmethod + def _missing_(cls, value: str) -> Optional['LogFormat']: + """Handle case-insensitive lookup of enum values.""" + try: + # Convert to uppercase and look up directly + return cls[value.upper()] + except (KeyError, AttributeError): + raise ValueError( + f"'{value}' is not a valid LogFormat. " + f"Valid formats are: {', '.join(format.value for format in cls)}" + ) + + +class ConfigurationError(Exception): + """Raised when there's an error in configuration.""" + def __init__(self, message: str) -> None: + super().__init__(message) + # You can add additional logging or handling here if needed + +@dataclass +class Config: + """Application configuration with priority resolution.""" + + port: int = 8989 + host: str = "localhost" + log_level: LogLevel = LogLevel.INFO + log_format: LogFormat = LogFormat.JSON + + def __post_init__(self) -> None: + """Validate configuration after initialization.""" + if not isinstance(self.port, int) or not (1 <= self.port <= 65535): + raise ConfigurationError("Port must be between 1 and 65535") + + if not isinstance(self.log_level, LogLevel): + try: + self.log_level = LogLevel(self.log_level) + except ValueError as e: + raise ConfigurationError(f"Invalid log level: {e}") + + if not isinstance(self.log_format, LogFormat): + try: + self.log_format = LogFormat(self.log_format) + except ValueError as e: + raise ConfigurationError(f"Invalid log format: {e}") + + @classmethod + def from_file(cls, config_path: Union[str, Path]) -> "Config": + """Load configuration from a YAML file. + + Args: + config_path: Path to the YAML configuration file + + Returns: + Config: Configuration instance + + Raises: + ConfigurationError: If the file cannot be read or parsed + """ + try: + with open(config_path, "r") as f: + config_data = yaml.safe_load(f) + + if not isinstance(config_data, dict): + raise ConfigurationError("Config file must contain a YAML dictionary") + + return cls( + port=config_data.get("port", cls.port), + host=config_data.get("host", cls.host), + log_level=config_data.get("log_level", cls.log_level.value), + log_format=config_data.get("log_format", cls.log_format.value), + ) + except yaml.YAMLError as e: + raise ConfigurationError(f"Failed to parse config file: {e}") + except OSError as e: + raise ConfigurationError(f"Failed to read config file: {e}") + + @classmethod + def from_env(cls) -> "Config": + """Load configuration from environment variables. + + Returns: + Config: Configuration instance + """ + try: + config = cls() + + if "CODEGATE_APP_PORT" in os.environ: + config.port = int(os.environ["CODEGATE_APP_PORT"]) + if "CODEGATE_APP_HOST" in os.environ: + config.host = os.environ["CODEGATE_APP_HOST"] + if "CODEGATE_APP_LOG_LEVEL" in os.environ: + config.log_level = LogLevel(os.environ["CODEGATE_APP_LOG_LEVEL"]) + if "CODEGATE_LOG_FORMAT" in os.environ: + config.log_format = LogFormat(os.environ["CODEGATE_LOG_FORMAT"]) + + return config + except ValueError as e: + raise ConfigurationError(f"Invalid environment variable value: {e}") + + @classmethod + def load( + cls, + config_path: Optional[Union[str, Path]] = None, + cli_port: Optional[int] = None, + cli_host: Optional[str] = None, + cli_log_level: Optional[str] = None, + cli_log_format: Optional[str] = None, + ) -> "Config": + """Load configuration with priority resolution. + + Priority order (highest to lowest): + 1. CLI arguments + 2. Environment variables + 3. Config file + 4. Default values + + Args: + config_path: Optional path to config file + cli_port: Optional CLI port override + cli_host: Optional CLI host override + cli_log_level: Optional CLI log level override + cli_log_format: Optional CLI log format override + + Returns: + Config: Resolved configuration + + Raises: + ConfigurationError: If configuration is invalid + """ + # Start with defaults + config = cls() + + # Load from config file if provided + if config_path: + try: + config = cls.from_file(config_path) + except ConfigurationError as e: + # Log warning but continue with defaults + import logging + logging.warning(f"Failed to load config file: {e}") + + # Override with environment variables + env_config = cls.from_env() + if "CODEGATE_APP_PORT" in os.environ: + config.port = env_config.port + if "CODEGATE_APP_HOST" in os.environ: + config.host = env_config.host + if "CODEGATE_APP_LOG_LEVEL" in os.environ: + config.log_level = env_config.log_level + if "CODEGATE_LOG_FORMAT" in os.environ: + config.log_format = env_config.log_format + + # Override with CLI arguments + if cli_port is not None: + config.port = cli_port + if cli_host is not None: + config.host = cli_host + if cli_log_level is not None: + config.log_level = LogLevel(cli_log_level) + if cli_log_format is not None: + config.log_format = LogFormat(cli_log_format) + + return config diff --git a/src/codegate/logging.py b/src/codegate/logging.py new file mode 100644 index 00000000..8ba0fe66 --- /dev/null +++ b/src/codegate/logging.py @@ -0,0 +1,161 @@ +import datetime +import json +import logging +import sys +from typing import Any, Optional + +from .config import LogFormat, LogLevel + + +class JSONFormatter(logging.Formatter): + """Custom formatter that outputs log records as JSON.""" + + def __init__(self) -> None: + """Initialize the JSON formatter.""" + super().__init__() + self.default_time_format = "%Y-%m-%dT%H:%M:%S" + self.default_msec_format = "%s.%03dZ" + + def format(self, record: logging.LogRecord) -> str: + """Format the log record as a JSON string. + + Args: + record: The log record to format + + Returns: + str: JSON formatted log entry + """ + # Create the base log entry + log_entry: dict[str, Any] = { + "timestamp": self.formatTime(record, self.default_time_format), + "level": record.levelname, + "module": record.module, + "message": record.getMessage(), + "extra": {}, + } + + # Add extra fields from the record + extra_attrs = {} + for key, value in record.__dict__.items(): + if key not in { + "args", "asctime", "created", "exc_info", "exc_text", "filename", + "funcName", "levelname", "levelno", "lineno", "module", "msecs", + "msg", "name", "pathname", "process", "processName", "relativeCreated", + "stack_info", "thread", "threadName", "extra" + }: + extra_attrs[key] = value + + # Handle the explicit extra parameter if present + if hasattr(record, "extra"): + try: + if isinstance(record.extra, dict): + extra_attrs.update(record.extra) + except Exception: + extra_attrs["unserializable_extra"] = str(record.extra) + + # Add all extra attributes to the log entry + if extra_attrs: + try: + json.dumps(extra_attrs) # Test if serializable + log_entry["extra"] = extra_attrs + except (TypeError, ValueError): + # If serialization fails, convert values to strings + serializable_extra = {} + for key, value in extra_attrs.items(): + try: + json.dumps({key: value}) # Test individual value + serializable_extra[key] = value + except (TypeError, ValueError): + serializable_extra[key] = str(value) + log_entry["extra"] = serializable_extra + + # Handle exception info if present + if record.exc_info: + log_entry["extra"]["exception"] = self.formatException(record.exc_info) + + # Handle stack info if present + if record.stack_info: + log_entry["extra"]["stack_info"] = self.formatStack(record.stack_info) + + return json.dumps(log_entry) + + +class TextFormatter(logging.Formatter): + """Standard text formatter with consistent timestamp format.""" + + def __init__(self) -> None: + """Initialize the text formatter.""" + super().__init__( + fmt="%(asctime)s - %(levelname)s - %(name)s - %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S.%03dZ" + ) + + def formatTime(self, record: logging.LogRecord, datefmt: Optional[str] = None) -> str: + """Format the time with millisecond precision. + + Args: + record: The log record + datefmt: The date format string (ignored as we use a fixed format) + + Returns: + str: Formatted timestamp + """ + ct = datetime.datetime.fromtimestamp(record.created, datetime.UTC) + return ct.strftime(self.datefmt) + + +def setup_logging( + log_level: Optional[LogLevel] = None, + log_format: Optional[LogFormat] = None +) -> None: + """Configure the logging system. + + Args: + log_level: The logging level to use. Defaults to INFO if not specified. + log_format: The log format to use. Defaults to JSON if not specified. + + This configures two handlers: + - stderr_handler: For ERROR, CRITICAL, and WARNING messages + - stdout_handler: For INFO and DEBUG messages + """ + if log_level is None: + log_level = LogLevel.INFO + if log_format is None: + log_format = LogFormat.JSON + + # Create formatters + json_formatter = JSONFormatter() + text_formatter = TextFormatter() + formatter = json_formatter if log_format == LogFormat.JSON else text_formatter + + # Create handlers for stdout and stderr + stdout_handler = logging.StreamHandler(sys.stdout) + stderr_handler = logging.StreamHandler(sys.stderr) + + # Set formatters + stdout_handler.setFormatter(formatter) + stderr_handler.setFormatter(formatter) + + # Configure log routing + stdout_handler.addFilter(lambda record: record.levelno <= logging.INFO) + stderr_handler.addFilter(lambda record: record.levelno > logging.INFO) + + # Get root logger and configure it + root_logger = logging.getLogger() + root_logger.setLevel(log_level.value) + + # Remove any existing handlers and add our new ones + root_logger.handlers.clear() + root_logger.addHandler(stdout_handler) + root_logger.addHandler(stderr_handler) + + # Create a logger for our package + logger = logging.getLogger("codegate") + logger.debug( + "Logging initialized", + extra={ + "log_level": log_level.value, + "log_format": log_format.value, + "handlers": ["stdout", "stderr"] + } + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..32e48764 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,92 @@ +"""Test configuration and fixtures.""" + +import datetime +import json +import os +from pathlib import Path +from typing import Any, Generator, Iterator +import pytest +import yaml +from unittest.mock import patch + +from codegate.config import Config, LogFormat + + +@pytest.fixture +def temp_config_file(tmp_path: Path) -> Iterator[Path]: + """Create a temporary config file.""" + config_data = { + "port": 8989, + "host": "localhost", + "log_level": "DEBUG", + "log_format": "JSON" + } + config_file = tmp_path / "config.yaml" + + with open(config_file, "w") as f: + yaml.dump(config_data, f) + + yield config_file + + +@pytest.fixture +def env_vars() -> Generator[None, None, None]: + """Set up test environment variables.""" + original_env = dict(os.environ) + + os.environ.update({ + "CODEGATE_APP_PORT": "8989", + "CODEGATE_APP_HOST": "localhost", + "CODEGATE_APP_LOG_LEVEL": "WARNING", + "CODEGATE_LOG_FORMAT": "TEXT" + }) + + yield + + # Restore original environment + os.environ.clear() + os.environ.update(original_env) + + +@pytest.fixture +def default_config() -> Config: + """Create a default configuration instance.""" + return Config() + + +@pytest.fixture +def mock_datetime() -> Generator[None, None, None]: + """Mock datetime to return a fixed time.""" + fixed_dt = datetime.datetime(2023, 1, 1, 12, 0, 0, tzinfo=datetime.UTC) + + with patch("datetime.datetime") as mock_dt: + mock_dt.now.return_value = fixed_dt + mock_dt.fromtimestamp.return_value = fixed_dt + mock_dt.UTC = datetime.UTC + yield + + +@pytest.fixture +def capture_logs(tmp_path: Path) -> Iterator[Path]: + """Capture logs to a file for testing.""" + log_file = tmp_path / "test.log" + + # Create a file handler + import logging + handler = logging.FileHandler(log_file) + logger = logging.getLogger() + logger.addHandler(handler) + + yield log_file + + # Clean up + handler.close() + logger.removeHandler(handler) + + +def parse_json_log(log_line: str) -> dict[str, Any]: + """Parse a JSON log line into a dictionary.""" + try: + return json.loads(log_line) + except json.JSONDecodeError as e: + pytest.fail(f"Invalid JSON log line: {e}") diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..df098157 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,162 @@ +"""Tests for command-line interface.""" + +from pathlib import Path +from typing import Any, Generator +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from codegate.cli import cli +from codegate.config import LogLevel, LogFormat + + +@pytest.fixture +def cli_runner() -> CliRunner: + """Create a Click CLI test runner.""" + return CliRunner() + + +@pytest.fixture +def mock_logging() -> Generator[Any, None, None]: + """Mock the logging setup.""" + with patch("codegate.cli.setup_logging") as mock: + yield mock + + +def test_cli_version(cli_runner: CliRunner) -> None: + """Test CLI version command.""" + result = cli_runner.invoke(cli, ["--version"]) + assert "version" in result.output.lower() + + +def test_serve_default_options(cli_runner: CliRunner, mock_logging: Any) -> None: + """Test serve command with default options.""" + with patch("logging.getLogger") as mock_logger: + logger_instance = mock_logger.return_value + result = cli_runner.invoke(cli, ["serve"]) + + assert result.exit_code == 0 + mock_logging.assert_called_once_with(LogLevel.INFO, LogFormat.JSON) + logger_instance.info.assert_any_call( + "Starting server", + extra={ + "host": "localhost", + "port": 8989, + "log_level": "INFO", + "log_format": "JSON", + } + ) + + +def test_serve_custom_options(cli_runner: CliRunner, mock_logging: Any) -> None: + """Test serve command with custom options.""" + with patch("logging.getLogger") as mock_logger: + logger_instance = mock_logger.return_value + result = cli_runner.invoke( + cli, + [ + "serve", + "--port", "8989", + "--host", "localhost", + "--log-level", "DEBUG", + "--log-format", "TEXT" + ] + ) + + assert result.exit_code == 0 + mock_logging.assert_called_once_with(LogLevel.DEBUG, LogFormat.TEXT) + logger_instance.info.assert_any_call( + "Starting server", + extra={ + "host": "localhost", + "port": 8989, + "log_level": "DEBUG", + "log_format": "TEXT", + } + ) + + +def test_serve_invalid_port(cli_runner: CliRunner) -> None: + """Test serve command with invalid port.""" + result = cli_runner.invoke(cli, ["serve", "--port", "0"]) + assert result.exit_code != 0 + assert "Port must be between 1 and 65535" in result.output + + result = cli_runner.invoke(cli, ["serve", "--port", "65536"]) + assert result.exit_code != 0 + assert "Port must be between 1 and 65535" in result.output + + +def test_serve_invalid_log_level(cli_runner: CliRunner) -> None: + """Test serve command with invalid log level.""" + result = cli_runner.invoke(cli, ["serve", "--log-level", "INVALID"]) + assert result.exit_code != 0 + assert "Invalid value for '--log-level'" in result.output + + +def test_serve_with_config_file(cli_runner: CliRunner, mock_logging: Any, temp_config_file: Path) -> None: + """Test serve command with config file.""" + with patch("logging.getLogger") as mock_logger: + logger_instance = mock_logger.return_value + result = cli_runner.invoke(cli, ["serve", "--config", str(temp_config_file)]) + + assert result.exit_code == 0 + mock_logging.assert_called_once_with(LogLevel.DEBUG, LogFormat.JSON) + logger_instance.info.assert_any_call( + "Starting server", + extra={ + "host": "localhost", + "port": 8989, + "log_level": "DEBUG", + "log_format": "JSON", + } + ) + +def test_serve_with_nonexistent_config_file(cli_runner: CliRunner) -> None: + """Test serve command with nonexistent config file.""" + result = cli_runner.invoke(cli, ["serve", "--config", "nonexistent.yaml"]) + assert result.exit_code != 0 + assert "does not exist" in result.output + + +def test_serve_priority_resolution( + cli_runner: CliRunner, + mock_logging: Any, + temp_config_file: Path, + env_vars: Any +) -> None: + """Test serve command respects configuration priority.""" + with patch("logging.getLogger") as mock_logger: + logger_instance = mock_logger.return_value + result = cli_runner.invoke( + cli, + [ + "serve", + "--config", str(temp_config_file), + "--port", "8080", + "--host", "example.com", + "--log-level", "ERROR", + "--log-format", "TEXT" + ] + ) + + assert result.exit_code == 0 + mock_logging.assert_called_once_with(LogLevel.ERROR, LogFormat.TEXT) + logger_instance.info.assert_any_call( + "Starting server", + extra={ + "host": "example.com", + "port": 8080, + "log_level": "ERROR", + "log_format": "TEXT", + } + ) + + +def test_main_function() -> None: + """Test main entry point function.""" + with patch("codegate.cli.cli") as mock_cli: + from codegate.cli import main + main() + mock_cli.assert_called_once() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..04b6b660 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,139 @@ +"""Tests for configuration management.""" + +import os +from pathlib import Path +import pytest +import yaml + +from codegate.config import Config, ConfigurationError, LogLevel, LogFormat + + +def test_default_config(default_config: Config) -> None: + """Test default configuration values.""" + assert default_config.port == 8989 + assert default_config.host == "localhost" + assert default_config.log_level == LogLevel.INFO + assert default_config.log_format == LogFormat.JSON + + +def test_config_from_file(temp_config_file: Path) -> None: + """Test loading configuration from file.""" + config = Config.from_file(temp_config_file) + assert config.port == 8989 + assert config.host == "localhost" + assert config.log_level == LogLevel.DEBUG + assert config.log_format == LogFormat.JSON + + +def test_config_from_invalid_file(tmp_path: Path) -> None: + """Test loading configuration from invalid file.""" + invalid_file = tmp_path / "invalid.yaml" + with open(invalid_file, "w") as f: + f.write("invalid: yaml: content") + + with pytest.raises(ConfigurationError): + Config.from_file(invalid_file) + + +def test_config_from_nonexistent_file() -> None: + """Test loading configuration from nonexistent file.""" + with pytest.raises(ConfigurationError): + Config.from_file("nonexistent.yaml") + + +def test_config_from_env(env_vars: None) -> None: + """Test loading configuration from environment variables.""" + config = Config.from_env() + assert config.port == 8989 + assert config.host == "localhost" + assert config.log_level == LogLevel.WARNING + assert config.log_format == LogFormat.TEXT + + +def test_config_priority_resolution(temp_config_file: Path, env_vars: None) -> None: + """Test configuration priority resolution.""" + # CLI args should override everything + config = Config.load( + config_path=temp_config_file, + cli_port=8080, + cli_host="example.com", + cli_log_level="WARNING", + cli_log_format="TEXT" + ) + assert config.port == 8080 + assert config.host == "example.com" + assert config.log_level == LogLevel.WARNING + assert config.log_format == LogFormat.TEXT + + # Env vars should override config file + config = Config.load(config_path=temp_config_file) + assert config.port == 8989 # from env + assert config.host == "localhost" # from env + assert config.log_level == LogLevel.WARNING # from env + assert config.log_format == LogFormat.TEXT # from env + + # Config file should override defaults + os.environ.clear() # Remove env vars + config = Config.load(config_path=temp_config_file) + assert config.port == 8989 # from file + assert config.host == "localhost" # from file + assert config.log_level == LogLevel.DEBUG # from file + assert config.log_format == LogFormat.JSON # from file + + +def test_invalid_log_level() -> None: + """Test handling of invalid log level.""" + with pytest.raises(ConfigurationError): + Config(log_level="INVALID") + + +def test_invalid_log_format() -> None: + """Test handling of invalid log format.""" + with pytest.raises(ConfigurationError): + Config(log_format="INVALID") + + +def test_invalid_port() -> None: + """Test handling of invalid port number.""" + with pytest.raises(ConfigurationError): + Config(port=0) + with pytest.raises(ConfigurationError): + Config(port=65536) + + +def test_log_format_case_insensitive(tmp_path: Path) -> None: + """Test log format is case insensitive.""" + config_file = tmp_path / "config.yaml" + with open(config_file, "w") as f: + yaml.dump({"log_format": "json"}, f) + + config = Config.from_file(config_file) + assert config.log_format == LogFormat.JSON + + with open(config_file, "w") as f: + yaml.dump({"log_format": "TEXT"}, f) + + config = Config.from_file(config_file) + assert config.log_format == LogFormat.TEXT + + +@pytest.fixture +def config_file_with_format(tmp_path: Path) -> Path: + """Create a config file with log format.""" + config_file = tmp_path / "config.yaml" + with open(config_file, "w") as f: + yaml.dump({ + "log_format": "TEXT", + "log_level": "DEBUG" + }, f) + return config_file + + +def test_env_var_priority(config_file_with_format: Path) -> None: + """Test environment variable priority for log format.""" + os.environ["CODEGATE_LOG_FORMAT"] = "JSON" + try: + config = Config.load(config_path=config_file_with_format) + assert config.log_format == LogFormat.JSON # env var overrides file + finally: + del os.environ["CODEGATE_LOG_FORMAT"] diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 00000000..d526fdd2 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,73 @@ +import logging +import json +import pytest +from io import StringIO +from codegate.logging import JSONFormatter, TextFormatter, setup_logging +from codegate.config import LogFormat, LogLevel + +def test_json_formatter(): + log_record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname=__file__, + lineno=10, + msg="Test message", + args=(), + exc_info=None + ) + formatter = JSONFormatter() + formatted_log = formatter.format(log_record) + log_entry = json.loads(formatted_log) + + assert log_entry["level"] == "INFO" + assert log_entry["module"] == "test_logging" + assert log_entry["message"] == "Test message" + assert "timestamp" in log_entry + assert "extra" in log_entry + +def test_text_formatter(): + log_record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname=__file__, + lineno=10, + msg="Test message", + args=(), + exc_info=None + ) + formatter = TextFormatter() + formatted_log = formatter.format(log_record) + + assert "INFO" in formatted_log + assert "test" in formatted_log + assert "Test message" in formatted_log + +def test_setup_logging_json_format(): + setup_logging(log_level=LogLevel.DEBUG, log_format=LogFormat.JSON) + logger = logging.getLogger("codegate") + log_output = StringIO() + handler = logging.StreamHandler(log_output) + handler.setFormatter(JSONFormatter()) + logger.addHandler(handler) + + logger.debug("Debug message") + log_output.seek(0) + log_entry = json.loads(log_output.getvalue().strip()) + + assert log_entry["level"] == "DEBUG" + assert log_entry["message"] == "Debug message" + +def test_setup_logging_text_format(): + setup_logging(log_level=LogLevel.DEBUG, log_format=LogFormat.TEXT) + logger = logging.getLogger("codegate") + log_output = StringIO() + handler = logging.StreamHandler(log_output) + handler.setFormatter(TextFormatter()) + logger.addHandler(handler) + + logger.debug("Debug message") + log_output.seek(0) + formatted_log = log_output.getvalue().strip() + + assert "DEBUG" in formatted_log + assert "Debug message" in formatted_log