diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 60fe345..8affb42 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -9,7 +9,7 @@ The `structkit` CLI allows you to generate project structures from YAML configur **Basic Usage:** ```sh -structkit {info,validate,generate,explain,vars,graph,list,sources,generate-schema,mcp,completion,init} ... +structkit {info,validate,generate,explain,vars,graph,list,sources,generate-schema,mcp,config,completion,init} ... ``` ## Global Options @@ -308,6 +308,60 @@ structkit completion install [bash|zsh|fish] - If no shell is provided, the command attempts to auto-detect your current shell and prints the exact commands to generate and install static completion files via shtab. - This does not modify your shell configuration; it only prints the commands you can copy-paste. +### `config` + +Display and manage structkit configuration. + +**Usage:** + +```sh +structkit config print [--format {yaml,json}] [-c CONFIG_FILE] +``` + +**Subcommands:** + +- `print`: Display the effective configuration after all layers are merged. + +**Arguments:** + +- `--format {yaml,json}`: Output format (default: yaml). +- `-c CONFIG_FILE, --config-file CONFIG_FILE`: Path to a project configuration file. + +**Description:** + +The `config print` command shows the final merged configuration from all layers: + +1. Built-in defaults (lowest priority) +2. User config (`~/.config/struct/config.yaml`) +3. Project config (`.struct.yaml` or `--config-file`) +4. CLI arguments (highest priority) + +The command also displays which configuration sources were loaded. + +**Examples:** + +View effective configuration: +```sh +structkit config print +``` + +View with project config: +```sh +structkit config print -c my-project-config.yaml +``` + +Output in JSON format: +```sh +structkit config print --format json +``` + +Override with CLI arguments: +```sh +structkit config print --log DEBUG -c config.yaml +``` + +See the [Configuration](configuration.md) documentation for more details on config layering. + ### `init` Initialize a basic .struct.yaml in the target directory. diff --git a/docs/configuration.md b/docs/configuration.md index 8914e4b..f2eded2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,90 @@ -# YAML Configuration +# Configuration -## Configuration Properties +## Config Layering + +Structkit supports a layered configuration system that allows you to set defaults at multiple levels. Configuration values are merged in the following order (from lowest to highest priority): + +1. **Built-in defaults** - Hard-coded defaults that are always present +2. **User config** - Global defaults from `~/.config/struct/config.yaml` +3. **Project config** - Project-specific config from `.struct.yaml` or `--config-file` +4. **CLI arguments** - Command-line flags (highest priority) + +### User Config + +You can create a user-level config file at `~/.config/struct/config.yaml` to set your personal defaults. This is useful for setting preferences that apply across all your projects. + +Example `~/.config/struct/config.yaml`: + +```yaml +structures_path: ~/my-custom-structures +input_store: ~/.cache/structkit/input.json +file_strategy: backup +log: WARNING +``` + +### Project Config + +Project-specific settings can be defined in a `.struct.yaml` file or specified via the `--config-file` flag. These settings override user config and built-in defaults. + +### CLI Arguments + +Command-line arguments always take the highest priority and override all config file settings. + +### Supported Config Options + +The following options can be configured via config files: + +- `structures_path` - Path to custom structure definitions +- `source` - Named source for structure definitions +- `input_store` - Path to the input store file +- `file_strategy` - Strategy for handling existing files (`overwrite`, `skip`, `append`, `rename`, `backup`) +- `backup` - Path to backup folder +- `global_system_prompt` - Global system prompt for OpenAI +- `non_interactive` - Run in non-interactive mode (boolean) +- `output` - Output mode (`file` or `console`) +- `log` - Logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) +- `log_file` - Path to log file + +### Viewing Effective Configuration + +To see the final merged configuration that structkit will use, run: + +```bash +structkit config print +``` + +This displays the effective configuration after all layers have been merged. You can also output in JSON format: + +```bash +structkit config print --format json +``` + +Example output: + +```yaml +backup: null +file_strategy: backup +global_system_prompt: null +input_store: /tmp/structkit/input.json +log: INFO +log_file: null +non_interactive: false +output: file +source: null +structures_path: /home/user/my-structures +``` + +The command also displays which configuration sources were used: + +``` +Configuration sources: + 1. Built-in defaults: always loaded + 2. User config: /home/user/.config/struct/config.yaml (exists) + 3. Project config: .struct.yaml + 4. CLI arguments: highest priority +``` + +## YAML Configuration Properties When defining your project structure in the YAML configuration file, you can use various properties to control the behavior of the script. Here are the available properties: diff --git a/examples/config-layering/README.md b/examples/config-layering/README.md new file mode 100644 index 0000000..7737ac5 --- /dev/null +++ b/examples/config-layering/README.md @@ -0,0 +1,159 @@ +# Config Layering Example + +This example demonstrates how to use structkit's config layering system to set defaults at multiple levels. + +## Overview + +Structkit supports configuration at four levels (from lowest to highest priority): + +1. **Built-in defaults** - Hard-coded baseline values +2. **User config** - Global defaults from `~/.config/struct/config.yaml` +3. **Project config** - Project-specific config from `.struct.yaml` or `--config-file` +4. **CLI arguments** - Command-line flags (highest priority) + +## Setup + +### 1. Create a User Config + +Set your personal global defaults: + +```bash +mkdir -p ~/.config/struct +cat > ~/.config/struct/config.yaml << 'EOF' +# Personal defaults that apply to all projects +file_strategy: backup +input_store: ~/.cache/structkit/input.json +log: WARNING +EOF +``` + +### 2. Create a Project Config + +Create a project-specific config file: + +```bash +cat > project-config.yaml << 'EOF' +# Project-specific overrides +file_strategy: skip +structures_path: ./custom-structures +backup: ./backups +EOF +``` + +## Usage + +### View Effective Configuration + +Display the merged configuration after all layers are applied: + +```bash +# View with user config only +structkit config print + +# View with project config override +structkit config print -c project-config.yaml + +# View in JSON format +structkit config print --format json + +# Override with CLI args +structkit config print -c project-config.yaml --log DEBUG +``` + +### Example Output + +With user config only: +```yaml +file_strategy: backup +input_store: /home/user/.cache/structkit/input.json +log: WARNING +non_interactive: false +output: file + +Configuration sources: + 1. Built-in defaults: always loaded + 2. User config: /home/user/.config/struct/config.yaml (exists) + 3. Project config: none specified + 4. CLI arguments: highest priority +``` + +With project config override: +```yaml +backup: ./backups +file_strategy: skip +input_store: /home/user/.cache/structkit/input.json +log: WARNING +non_interactive: false +output: file +structures_path: ./custom-structures + +Configuration sources: + 1. Built-in defaults: always loaded + 2. User config: /home/user/.config/struct/config.yaml (exists) + 3. Project config: project-config.yaml + 4. CLI arguments: highest priority +``` + +Notice how: +- `file_strategy` changed from `backup` (user config) to `skip` (project config) +- `input_store` remained from user config (not overridden by project) +- `structures_path` and `backup` are new from project config + +## Supported Config Options + +The following options can be set in config files: + +- `structures_path` - Path to custom structure definitions +- `source` - Named source for structure definitions +- `input_store` - Path to the input store file +- `file_strategy` - Strategy for handling existing files (`overwrite`, `skip`, `append`, `rename`, `backup`) +- `backup` - Path to backup folder +- `global_system_prompt` - Global system prompt for OpenAI +- `non_interactive` - Run in non-interactive mode (boolean) +- `output` - Output mode (`file` or `console`) +- `log` - Logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) +- `log_file` - Path to log file + +## Precedence Rules + +When the same option is set at multiple levels: + +1. CLI arguments always win +2. Project config overrides user config +3. User config overrides built-in defaults +4. Built-in defaults are always present + +## Common Use Cases + +### Personal Backup Strategy + +Set your preferred file strategy globally: +```yaml +# ~/.config/struct/config.yaml +file_strategy: backup +backup: ~/structkit-backups +``` + +### Team Project Standards + +Share project-specific settings in version control: +```yaml +# .struct.yaml (checked into git) +structures_path: ./team-structures +input_store: ./.structkit/input.json +non_interactive: true +``` + +### Temporary Overrides + +Override for a single command: +```bash +structkit generate --file-strategy overwrite --log DEBUG +``` + +## Tips + +1. Use user config for personal preferences that apply to all projects +2. Use project config for team standards and project-specific paths +3. Use CLI args for one-off overrides during development +4. Run `structkit config print` to debug configuration issues diff --git a/examples/config-layering/project-config-example.yaml b/examples/config-layering/project-config-example.yaml new file mode 100644 index 0000000..d52cde2 --- /dev/null +++ b/examples/config-layering/project-config-example.yaml @@ -0,0 +1,19 @@ +# Example project config file +# +# This file can be checked into version control to share project-specific +# settings with your team. Use with --config-file or as .struct.yaml + +# Project-specific structures location +structures_path: ./custom-structures + +# Project-specific input store +input_store: ./.structkit/input.json + +# File strategy for this project +file_strategy: skip + +# Backup location for this project +backup: ./backups + +# Run in non-interactive mode for CI/CD +non_interactive: true diff --git a/examples/config-layering/user-config-example.yaml b/examples/config-layering/user-config-example.yaml new file mode 100644 index 0000000..d511eec --- /dev/null +++ b/examples/config-layering/user-config-example.yaml @@ -0,0 +1,28 @@ +# Example user config file for ~/.config/struct/config.yaml +# +# This file contains personal defaults that apply to all your structkit projects. +# Copy this to ~/.config/struct/config.yaml and customize as needed. + +# Default file handling strategy +# Options: overwrite, skip, append, rename, backup +file_strategy: backup + +# Where to store user input history +input_store: ~/.cache/structkit/input.json + +# Default logging level +# Options: DEBUG, INFO, WARNING, ERROR, CRITICAL +log: WARNING + +# Path to custom structure definitions +# structures_path: ~/my-structures + +# Run in non-interactive mode by default +# non_interactive: false + +# Default output mode +# Options: file, console +# output: file + +# Default backup directory +# backup: ~/structkit-backups diff --git a/structkit/commands/config.py b/structkit/commands/config.py new file mode 100644 index 0000000..6c78095 --- /dev/null +++ b/structkit/commands/config.py @@ -0,0 +1,82 @@ +"""Config command for displaying effective configuration.""" + +from structkit.commands import Command +import yaml +import json +from structkit.config import get_effective_config, get_user_config_path, get_builtin_defaults + + +class ConfigCommand(Command): + def __init__(self, parser): + # Don't call super().__init__() yet - we need to set up subparsers first + self.parser = parser + import logging + self.logger = logging.getLogger(__name__) + + parser.description = "Display and manage structkit configuration" + + # Create subparsers for config subcommands + subparsers = parser.add_subparsers(dest='config_subcommand', help='Config subcommand') + + # print subcommand + print_parser = subparsers.add_parser('print', help='Print the effective configuration') + print_parser.add_argument( + '--format', + type=str, + choices=['yaml', 'json'], + default='yaml', + help='Output format (default: yaml)' + ) + print_parser.set_defaults(config_func=self.print_config) + + # Add common arguments to the main parser and all subparsers + for p in [parser, print_parser]: + self._add_common_args(p) + + parser.set_defaults(func=self.execute) + + def _add_common_args(self, parser): + """Add common arguments to a parser (replicating Command base class).""" + from structkit.completers import log_level_completer + parser.add_argument('-l', '--log', type=str, default='INFO', help='Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)').completer = log_level_completer + parser.add_argument('-c', '--config-file', type=str, help='Path to a configuration file') + parser.add_argument('-i', '--log-file', type=str, help='Path to a log file') + + def execute(self, args): + """Execute the config command.""" + # If no subcommand is provided, show help + if not hasattr(args, 'config_func'): + self.parser.print_help() + return + + # Execute the subcommand + args.config_func(args) + + def print_config(self, args): + """Print the effective configuration after all layers are merged. + + This shows the final configuration that structkit would use, + combining built-in defaults, user config, project config, and CLI args. + """ + # Get effective config + effective_config = get_effective_config(args) + + # Format and print + if args.format == 'json': + output = json.dumps(effective_config, indent=2, sort_keys=True) + else: # yaml + output = yaml.dump(effective_config, default_flow_style=False, sort_keys=True) + + print(output.rstrip()) + + # Show config sources + user_config_path = get_user_config_path() + self.logger.info("") + self.logger.info("Configuration sources:") + self.logger.info(" 1. Built-in defaults: always loaded") + self.logger.info(f" 2. User config: {user_config_path} {'(exists)' if user_config_path.exists() else '(not found)'}") + if hasattr(args, 'config_file') and args.config_file: + self.logger.info(f" 3. Project config: {args.config_file}") + else: + self.logger.info(" 3. Project config: none specified") + self.logger.info(" 4. CLI arguments: highest priority") diff --git a/structkit/config.py b/structkit/config.py new file mode 100644 index 0000000..dbe38b9 --- /dev/null +++ b/structkit/config.py @@ -0,0 +1,202 @@ +"""Configuration layering system for structkit. + +Supports loading and merging configuration from multiple sources: +1. Built-in defaults +2. User config (~/.config/struct/config.yaml) +3. Project config (.struct.yaml or --config-file) +4. CLI arguments + +Priority order: CLI args > Project config > User config > Built-in defaults +""" + +import os +import yaml +from pathlib import Path +from typing import Dict, Any, Optional +import logging + +logger = logging.getLogger(__name__) + + +def get_user_config_path() -> Path: + """Get the path to the user config file. + + Returns ~/.config/struct/config.yaml + """ + return Path.home() / ".config" / "struct" / "config.yaml" + + +def get_builtin_defaults() -> Dict[str, Any]: + """Return built-in default configuration values. + + These are the lowest priority defaults used when no other config is provided. + """ + return { + 'file_strategy': 'overwrite', + 'input_store': '/tmp/structkit/input.json', + 'log': 'INFO', + 'non_interactive': False, + 'output': 'file', + } + + +def load_yaml_config(config_path: str) -> Optional[Dict[str, Any]]: + """Load a YAML config file and return its contents. + + Args: + config_path: Path to the YAML config file + + Returns: + Dictionary with config values, or None if file doesn't exist or is empty + """ + if not config_path or not os.path.exists(config_path): + return None + + try: + with open(config_path, 'r') as f: + config = yaml.safe_load(f) + if config is None: + return {} + if not isinstance(config, dict): + logger.warning(f"Config file {config_path} does not contain a mapping, ignoring") + return {} + return config + except yaml.YAMLError as exc: + logger.warning(f"Failed to parse YAML in {config_path}: {exc}") + return {} + except OSError as exc: + logger.warning(f"Failed to read {config_path}: {exc}") + return {} + + +def merge_config_layer(base: Dict[str, Any], override: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Merge an override config layer into a base config. + + Override values take precedence over base values. Only merges top-level keys. + + Args: + base: Base configuration dictionary + override: Override configuration dictionary (can be None) + + Returns: + Merged configuration dictionary + """ + if override is None: + return base.copy() + + result = base.copy() + for key, value in override.items(): + if value is not None: + result[key] = value + return result + + +def load_layered_config(project_config_path: Optional[str] = None) -> Dict[str, Any]: + """Load configuration from all layers and merge them. + + Merge order (lowest to highest priority): + 1. Built-in defaults + 2. User config (~/.config/struct/config.yaml) + 3. Project config (if provided) + + Args: + project_config_path: Path to project-specific config file + + Returns: + Merged configuration dictionary + """ + # Start with built-in defaults + config = get_builtin_defaults() + + # Layer in user config + user_config_path = get_user_config_path() + if user_config_path.exists(): + logger.debug(f"Loading user config from {user_config_path}") + user_config = load_yaml_config(str(user_config_path)) + config = merge_config_layer(config, user_config) + + # Layer in project config + if project_config_path: + logger.debug(f"Loading project config from {project_config_path}") + project_config = load_yaml_config(project_config_path) + config = merge_config_layer(config, project_config) + + return config + + +def merge_cli_args(config: Dict[str, Any], args) -> Dict[str, Any]: + """Merge CLI arguments into configuration, giving CLI args highest priority. + + Args: + config: Configuration dictionary from layered config files + args: argparse Namespace with CLI arguments + + Returns: + Final merged configuration dictionary + """ + result = config.copy() + args_dict = vars(args) + + # List of config keys that can be set via CLI args + # We only override config file values if the CLI arg was explicitly provided + # (i.e., it's not None and differs from the default) + cli_overridable_keys = [ + 'structures_path', + 'input_store', + 'file_strategy', + 'backup', + 'global_system_prompt', + 'non_interactive', + 'output', + 'log', + 'log_file', + 'source', + ] + + for key in cli_overridable_keys: + if key in args_dict and args_dict[key] is not None: + result[key] = args_dict[key] + + return result + + +def apply_config_to_args(config: Dict[str, Any], args): + """Apply configuration values to argparse namespace. + + This modifies the args namespace in place, setting values from the config + only if the arg was not explicitly set via CLI. + + Args: + config: Configuration dictionary + args: argparse Namespace to update + """ + args_dict = vars(args) + + for key, value in config.items(): + # Only set the value if the arg doesn't already have a non-None value + # This ensures CLI args take precedence + if key in args_dict and args_dict[key] is None: + args_dict[key] = value + + +def get_effective_config(args) -> Dict[str, Any]: + """Get the effective configuration by merging all layers including CLI args. + + This is the final configuration that would be used after all merging is complete. + + Args: + args: argparse Namespace with CLI arguments + + Returns: + Effective configuration dictionary + """ + # Get the project config file path from args if present + project_config_path = getattr(args, 'config_file', None) + + # Load layered config from files + config = load_layered_config(project_config_path) + + # Merge in CLI args (highest priority) + config = merge_cli_args(config, args) + + return config diff --git a/structkit/main.py b/structkit/main.py index 401dfdf..12ca15d 100644 --- a/structkit/main.py +++ b/structkit/main.py @@ -4,6 +4,7 @@ import shlex from dotenv import load_dotenv from structkit.utils import read_config_file, merge_configs +from structkit.config import load_layered_config, apply_config_to_args from structkit.commands.generate import GenerateCommand from structkit.commands.info import InfoCommand from structkit.commands.vars import VarsCommand @@ -16,6 +17,7 @@ from structkit.commands.generate_schema import GenerateSchemaCommand from structkit.commands.mcp import MCPCommand from structkit.commands.sources import SourcesCommand +from structkit.commands.config import ConfigCommand from structkit.logging_config import configure_logging # Optional dependency: shtab for static shell completion generation @@ -137,6 +139,7 @@ def get_parser(): GenerateSchemaCommand(subparsers.add_parser('generate-schema', help='Generate JSON schema for available structures')) MCPCommand(subparsers.add_parser('mcp', help='MCP (Model Context Protocol) support')) SourcesCommand(subparsers.add_parser('sources', help='Manage named custom structure sources')) + ConfigCommand(subparsers.add_parser('config', help='Display and manage structkit configuration')) # init to create a basic .struct.yaml from structkit.commands.init import InitCommand @@ -168,9 +171,18 @@ def main(): parser.print_help() parser.exit() - # Read config file if provided - if getattr(args, 'config_file', None): - file_config = read_config_file(args.config_file) + # Load layered configuration (built-in defaults, user config, project config) + # This loads from ~/.config/struct/config.yaml and any --config-file + project_config_path = getattr(args, 'config_file', None) + layered_config = load_layered_config(project_config_path) + + # Apply config to args (only for values not set via CLI) + apply_config_to_args(layered_config, args) + + # For backward compatibility, also support the old merge_configs approach + # if config_file was explicitly provided + if project_config_path: + file_config = read_config_file(project_config_path) args = argparse.Namespace(**merge_configs(file_config, args)) # Resolve logging level precedence: STRUCTKIT_LOG_LEVEL env > --debug (if present) > --log diff --git a/tests/test_config_command.py b/tests/test_config_command.py new file mode 100644 index 0000000..6d8fab2 --- /dev/null +++ b/tests/test_config_command.py @@ -0,0 +1,213 @@ +"""Tests for the config command.""" + +import pytest +import argparse +import tempfile +import os +import yaml +import json +from unittest.mock import patch, MagicMock +from pathlib import Path +from structkit.commands.config import ConfigCommand + + +@pytest.fixture +def parser(): + return argparse.ArgumentParser() + + +@pytest.fixture +def config_command(parser): + return ConfigCommand(parser) + + +def test_config_command_no_subcommand(parser, capsys): + """Test config command without subcommand shows help.""" + command = ConfigCommand(parser) + args = parser.parse_args([]) + + command.execute(args) + + captured = capsys.readouterr() + assert 'usage:' in captured.out or 'Display and manage structkit configuration' in captured.out + + +def test_config_print_yaml_format(parser): + """Test config print command with YAML format.""" + command = ConfigCommand(parser) + + # Create a temporary config file + config_data = """ +file_strategy: skip +input_store: /custom/input.json +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(config_data) + f.flush() + temp_path = f.name + + try: + with patch('structkit.config.get_user_config_path') as mock_user_path: + mock_user_path.return_value = Path('/nonexistent/user/config.yaml') + + args = parser.parse_args(['print', '--format', 'yaml', '-c', temp_path]) + args.config_file = temp_path + + with patch('builtins.print') as mock_print: + command.execute(args) + mock_print.assert_called() + + # Get the printed output + printed_output = mock_print.call_args[0][0] + + # Parse the YAML output + printed_config = yaml.safe_load(printed_output) + + # Check that config was merged correctly + assert printed_config['file_strategy'] == 'skip' + assert printed_config['input_store'] == '/custom/input.json' + finally: + os.unlink(temp_path) + + +def test_config_print_json_format(parser): + """Test config print command with JSON format.""" + command = ConfigCommand(parser) + + # Create a temporary config file + config_data = """ +file_strategy: backup +structures_path: /custom/structures +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(config_data) + f.flush() + temp_path = f.name + + try: + with patch('structkit.config.get_user_config_path') as mock_user_path: + mock_user_path.return_value = Path('/nonexistent/user/config.yaml') + + args = parser.parse_args(['print', '--format', 'json', '-c', temp_path]) + args.config_file = temp_path + + with patch('builtins.print') as mock_print: + command.execute(args) + mock_print.assert_called() + + # Get the printed output + printed_output = mock_print.call_args[0][0] + + # Parse the JSON output + printed_config = json.loads(printed_output) + + # Check that config was merged correctly + assert printed_config['file_strategy'] == 'backup' + assert printed_config['structures_path'] == '/custom/structures' + finally: + os.unlink(temp_path) + + +def test_config_print_with_cli_override(parser): + """Test that CLI args override config file values in print output.""" + command = ConfigCommand(parser) + + # Create a temporary config file + config_data = """ +file_strategy: skip +log: WARNING +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(config_data) + f.flush() + temp_path = f.name + + try: + with patch('structkit.config.get_user_config_path') as mock_user_path: + mock_user_path.return_value = Path('/nonexistent/user/config.yaml') + + # Simulate CLI args that override config + args = parser.parse_args(['print', '-c', temp_path, '--log', 'DEBUG']) + args.config_file = temp_path + + with patch('builtins.print') as mock_print: + command.execute(args) + + # Get the printed output + printed_output = mock_print.call_args[0][0] + printed_config = yaml.safe_load(printed_output) + + # CLI arg should override config file + assert printed_config['log'] == 'DEBUG' + finally: + os.unlink(temp_path) + + +def test_config_print_default_format(parser): + """Test that config print defaults to YAML format.""" + command = ConfigCommand(parser) + + with patch('structkit.config.get_user_config_path') as mock_user_path: + mock_user_path.return_value = Path('/nonexistent/user/config.yaml') + + args = parser.parse_args(['print']) + + with patch('builtins.print') as mock_print: + command.execute(args) + mock_print.assert_called() + + # Get the printed output + printed_output = mock_print.call_args[0][0] + + # Should be valid YAML + printed_config = yaml.safe_load(printed_output) + assert isinstance(printed_config, dict) + assert 'file_strategy' in printed_config + + +def test_config_print_shows_all_layers(parser): + """Test that config print merges all config layers.""" + command = ConfigCommand(parser) + + user_config_data = """ +input_store: /user/input.json +file_strategy: skip +""" + project_config_data = """ +file_strategy: backup +structures_path: /project/structures +""" + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as user_f, \ + tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as project_f: + user_f.write(user_config_data) + user_f.flush() + temp_user_path = user_f.name + + project_f.write(project_config_data) + project_f.flush() + temp_project_path = project_f.name + + try: + with patch('structkit.config.get_user_config_path') as mock_user_path: + mock_user_path.return_value = Path(temp_user_path) + + args = parser.parse_args(['print', '-c', temp_project_path]) + args.config_file = temp_project_path + + with patch('builtins.print') as mock_print: + command.execute(args) + + # Get the printed output + printed_output = mock_print.call_args[0][0] + printed_config = yaml.safe_load(printed_output) + + # Project config should override user config + assert printed_config['file_strategy'] == 'backup' # From project + assert printed_config['input_store'] == '/user/input.json' # From user + assert printed_config['structures_path'] == '/project/structures' # From project + # Should also have built-in defaults + assert 'log' in printed_config + finally: + os.unlink(temp_user_path) + os.unlink(temp_project_path) diff --git a/tests/test_config_layering.py b/tests/test_config_layering.py new file mode 100644 index 0000000..bbc054b --- /dev/null +++ b/tests/test_config_layering.py @@ -0,0 +1,297 @@ +"""Tests for config layering system.""" + +import pytest +import os +import tempfile +import argparse +from pathlib import Path +from unittest.mock import patch, MagicMock +from structkit.config import ( + get_builtin_defaults, + load_yaml_config, + merge_config_layer, + load_layered_config, + merge_cli_args, + apply_config_to_args, + get_effective_config, + get_user_config_path, +) + + +def test_get_builtin_defaults(): + """Test that built-in defaults contain expected keys.""" + defaults = get_builtin_defaults() + assert 'file_strategy' in defaults + assert defaults['file_strategy'] == 'overwrite' + assert 'input_store' in defaults + assert 'log' in defaults + assert defaults['log'] == 'INFO' + + +def test_load_yaml_config_file_not_exists(): + """Test loading config from non-existent file returns None.""" + result = load_yaml_config('/nonexistent/path/config.yaml') + assert result is None + + +def test_load_yaml_config_empty_file(): + """Test loading empty config file returns empty dict.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write('') + f.flush() + temp_path = f.name + + try: + result = load_yaml_config(temp_path) + assert result == {} + finally: + os.unlink(temp_path) + + +def test_load_yaml_config_valid_file(): + """Test loading valid config file.""" + config_data = """ +file_strategy: skip +input_store: /custom/path/input.json +structures_path: /custom/structures +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(config_data) + f.flush() + temp_path = f.name + + try: + result = load_yaml_config(temp_path) + assert result is not None + assert result['file_strategy'] == 'skip' + assert result['input_store'] == '/custom/path/input.json' + assert result['structures_path'] == '/custom/structures' + finally: + os.unlink(temp_path) + + +def test_merge_config_layer_base_only(): + """Test merging with None override returns copy of base.""" + base = {'key1': 'value1', 'key2': 'value2'} + result = merge_config_layer(base, None) + assert result == base + assert result is not base # Should be a copy + + +def test_merge_config_layer_with_override(): + """Test merging override into base.""" + base = {'key1': 'value1', 'key2': 'value2', 'key3': 'value3'} + override = {'key2': 'new_value2', 'key4': 'value4'} + result = merge_config_layer(base, override) + + assert result['key1'] == 'value1' # Unchanged from base + assert result['key2'] == 'new_value2' # Overridden + assert result['key3'] == 'value3' # Unchanged from base + assert result['key4'] == 'value4' # New from override + + +def test_merge_config_layer_none_values(): + """Test that None values in override are applied.""" + base = {'key1': 'value1', 'key2': 'value2'} + override = {'key2': None} + result = merge_config_layer(base, override) + + # None values in override should not override base + # (based on implementation, None is skipped) + assert result['key1'] == 'value1' + assert result['key2'] == 'value2' + + +def test_load_layered_config_only_defaults(): + """Test loading layered config with no user or project config.""" + with patch('structkit.config.get_user_config_path') as mock_user_path: + mock_user_path.return_value = Path('/nonexistent/user/config.yaml') + + config = load_layered_config(None) + + # Should just be built-in defaults + defaults = get_builtin_defaults() + assert config == defaults + + +def test_load_layered_config_with_user_config(): + """Test loading layered config with user config present.""" + user_config_data = """ +file_strategy: skip +input_store: /user/custom/input.json +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(user_config_data) + f.flush() + temp_user_path = f.name + + try: + with patch('structkit.config.get_user_config_path') as mock_user_path: + mock_user_path.return_value = Path(temp_user_path) + + config = load_layered_config(None) + + # Should have defaults merged with user config + assert config['file_strategy'] == 'skip' + assert config['input_store'] == '/user/custom/input.json' + assert 'log' in config # From defaults + finally: + os.unlink(temp_user_path) + + +def test_load_layered_config_with_project_config(): + """Test loading layered config with project config.""" + project_config_data = """ +file_strategy: backup +structures_path: /project/structures +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(project_config_data) + f.flush() + temp_project_path = f.name + + try: + with patch('structkit.config.get_user_config_path') as mock_user_path: + mock_user_path.return_value = Path('/nonexistent/user/config.yaml') + + config = load_layered_config(temp_project_path) + + # Should have defaults merged with project config + assert config['file_strategy'] == 'backup' + assert config['structures_path'] == '/project/structures' + assert 'log' in config # From defaults + finally: + os.unlink(temp_project_path) + + +def test_config_precedence(): + """Test full config precedence: defaults < user < project.""" + user_config_data = """ +file_strategy: skip +input_store: /user/input.json +backup: /user/backup +""" + project_config_data = """ +file_strategy: backup +backup: /project/backup +structures_path: /project/structures +""" + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as user_f, \ + tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as project_f: + user_f.write(user_config_data) + user_f.flush() + temp_user_path = user_f.name + + project_f.write(project_config_data) + project_f.flush() + temp_project_path = project_f.name + + try: + with patch('structkit.config.get_user_config_path') as mock_user_path: + mock_user_path.return_value = Path(temp_user_path) + + config = load_layered_config(temp_project_path) + + # Project config should override user config + assert config['file_strategy'] == 'backup' # From project (overrides user) + assert config['backup'] == '/project/backup' # From project (overrides user) + assert config['input_store'] == '/user/input.json' # From user (not in project) + assert config['structures_path'] == '/project/structures' # From project only + assert 'log' in config # From defaults + finally: + os.unlink(temp_user_path) + os.unlink(temp_project_path) + + +def test_merge_cli_args(): + """Test merging CLI args into config.""" + config = { + 'file_strategy': 'skip', + 'input_store': '/config/input.json', + 'log': 'INFO', + } + + args = argparse.Namespace( + file_strategy='overwrite', # Override from CLI + input_store=None, # Not set via CLI + backup='/cli/backup', # New from CLI + log='DEBUG', # Override from CLI + ) + + result = merge_cli_args(config, args) + + assert result['file_strategy'] == 'overwrite' # CLI override + assert result['input_store'] == '/config/input.json' # From config + assert result['backup'] == '/cli/backup' # CLI arg + assert result['log'] == 'DEBUG' # CLI override + + +def test_apply_config_to_args(): + """Test applying config to argparse namespace.""" + config = { + 'file_strategy': 'skip', + 'input_store': '/config/input.json', + 'backup': '/config/backup', + 'structures_path': '/config/structures', + } + + args = argparse.Namespace( + file_strategy='overwrite', # Already set via CLI + input_store=None, # Not set, should get from config + backup=None, # Not set, should get from config + structures_path=None, # Not set, should get from config + ) + + apply_config_to_args(config, args) + + # CLI arg should not be overridden + assert args.file_strategy == 'overwrite' + + # Config values should be applied where args were None + assert args.input_store == '/config/input.json' + assert args.backup == '/config/backup' + assert args.structures_path == '/config/structures' + + +def test_get_effective_config(): + """Test getting effective config with all layers.""" + project_config_data = """ +file_strategy: backup +structures_path: /project/structures +""" + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(project_config_data) + f.flush() + temp_project_path = f.name + + try: + with patch('structkit.config.get_user_config_path') as mock_user_path: + mock_user_path.return_value = Path('/nonexistent/user/config.yaml') + + args = argparse.Namespace( + config_file=temp_project_path, + file_strategy='overwrite', # CLI override + input_store=None, + backup=None, + structures_path=None, + log='DEBUG', # CLI override + ) + + effective_config = get_effective_config(args) + + # Check precedence + assert effective_config['file_strategy'] == 'overwrite' # CLI wins + assert effective_config['structures_path'] == '/project/structures' # From project + assert effective_config['log'] == 'DEBUG' # CLI wins + assert 'input_store' in effective_config # From defaults + finally: + os.unlink(temp_project_path) + + +def test_user_config_path(): + """Test that user config path is in expected location.""" + path = get_user_config_path() + assert str(path).endswith('.config/struct/config.yaml') + assert path.is_absolute()