From 65c6c3dc37b25ed01962fbc66dd9a1462a6dfb1a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Jan 2026 08:55:32 +0000 Subject: [PATCH 1/3] Add YAML set operations to config-utils Implement five set operation commands for comparing YAML files: - union: All keys/values present in either file - intersect: Only keys/values present in both files - diff: Keys/values in file1 but not in file2 - rdiff: Keys/values in file2 but not in file1 - symdiff: Keys/values in either file but not both Features: - Two comparison modes: keys (ignore values) or kv (compare key-value pairs) - Depth control: 1 (root only), 2+ (nested levels), 0 (unlimited) - Comprehensive error handling for missing files, invalid YAML, non-dict roots - All operations output valid YAML to stdout - Exit code 0 on success, 1 on error Updated version to 0.2.0 and added extensive documentation with examples. --- tools/config-utils/README.md | 121 ++++++++++++- tools/config-utils/cli.py | 277 +++++++++++++++++++++++++++++- tools/config-utils/pyproject.toml | 4 +- 3 files changed, 398 insertions(+), 4 deletions(-) diff --git a/tools/config-utils/README.md b/tools/config-utils/README.md index 954d2df..084bf21 100644 --- a/tools/config-utils/README.md +++ b/tools/config-utils/README.md @@ -1,11 +1,12 @@ # config-utils -A CLI tool for capturing environment variables and Django settings in YAML format. +A CLI tool for capturing environment variables and Django settings in YAML format, plus performing set operations on YAML configuration files. ## Features - **capture-env**: Capture all environment variables and export them to YAML - **capture-django-settings**: Capture Django project settings and export them to YAML +- **Set Operations**: Compare and merge YAML files using set operations (union, intersect, diff, rdiff, symdiff) ## Installation @@ -116,6 +117,124 @@ config-utils capture-django-settings **Note**: This command must be run from your Django project directory or you must specify the path to `manage.py` using the `--manage-py` option. +### YAML Set Operations + +Perform set operations on two YAML configuration files. All operations output valid YAML to stdout. + +#### Commands + +- `union`: Returns all keys (or key-value pairs) present in either file +- `intersect`: Returns only keys (or key-value pairs) present in both files +- `diff`: Returns keys (or key-value pairs) in file1 but not in file2 (A - B) +- `rdiff`: Returns keys (or key-value pairs) in file2 but not in file1 (B - A) +- `symdiff`: Returns keys (or key-value pairs) in either file but not in both (symmetric difference) + +#### Options + +- `--compare `: Comparison mode - `keys` or `kv` (key-values). Default: `kv` + - `kv`: Compares key-value pairs. Two entries match only if both key AND value are identical + - `keys`: Compares only keys. Values are ignored when determining matches +- `--depth `: How many levels deep to compare. Default: `1` + - `1`: Root keys only + - `2`: Compare up to 2 levels (root and one level of nesting) + - `0`: Unlimited depth (fully flatten using dot notation) + +#### Examples + +**file1.yml:** +```yaml +database: postgres +port: 5432 +debug: true +``` + +**file2.yml:** +```yaml +database: postgres +port: 3306 +logging: verbose +``` + +**Find common configuration (intersect with key-values):** +```bash +config-utils intersect file1.yml file2.yml +# Output: +# database: postgres +``` + +**Find common keys regardless of values:** +```bash +config-utils intersect file1.yml file2.yml --compare keys +# Output: +# database: postgres +# port: 5432 +``` + +**Find all unique configuration (union):** +```bash +config-utils union file1.yml file2.yml +# Output: +# database: postgres +# port: 5432 +# debug: true +# logging: verbose +``` + +**Find what's in file1 but not file2 (diff):** +```bash +config-utils diff file1.yml file2.yml +# Output: +# port: 5432 +# debug: true +``` + +**Find what's in file2 but not file1 (rdiff):** +```bash +config-utils rdiff file1.yml file2.yml +# Output: +# port: 3306 +# logging: verbose +``` + +**Nested comparison with depth:** + +**nested1.yml:** +```yaml +database: + host: localhost + port: 5432 +app: + name: myapp +``` + +**nested2.yml:** +```yaml +database: + host: localhost + port: 3306 +app: + name: myapp +``` + +```bash +# Depth 1 - root keys only +config-utils intersect nested1.yml nested2.yml --depth 1 +# Output: +# database: +# host: localhost +# port: 5432 +# app: +# name: myapp + +# Depth 2 - compare nested keys +config-utils intersect nested1.yml nested2.yml --depth 2 +# Output: +# database: +# host: localhost +# app: +# name: myapp +``` + ### Using with uvx You can run the tool directly without installation: diff --git a/tools/config-utils/cli.py b/tools/config-utils/cli.py index da075c9..5c60a7f 100644 --- a/tools/config-utils/cli.py +++ b/tools/config-utils/cli.py @@ -6,13 +6,224 @@ import subprocess import json from pathlib import Path +from typing import Dict, Any, Set, Tuple import click +def flatten_dict(data: Dict[str, Any], depth: int, parent_key: str = '', sep: str = '.') -> Dict[str, Any]: + """ + Flatten a nested dictionary to a specified depth. + + Args: + data: The dictionary to flatten + depth: How many levels deep to flatten (0 = unlimited, 1 = root only, no flattening) + parent_key: The parent key for recursion + sep: The separator for nested keys + + Returns: + Flattened dictionary + """ + # Depth 1 means no flattening - just return the dict as is + if depth == 1 and not parent_key: + return data.copy() + + items = {} + current_depth = len(parent_key.split(sep)) if parent_key else 0 + + for key, value in data.items(): + new_key = f"{parent_key}{sep}{key}" if parent_key else key + + # Calculate the depth of the new key + new_depth = len(new_key.split(sep)) + + # If we've reached the depth limit, don't flatten further + if depth > 0 and new_depth >= depth: + items[new_key] = value + elif isinstance(value, dict) and value: + # Continue flattening + items.update(flatten_dict(value, depth, new_key, sep=sep)) + else: + items[new_key] = value + + return items + + +def unflatten_dict(data: Dict[str, Any], sep: str = '.') -> Dict[str, Any]: + """ + Unflatten a dictionary with dot-separated keys back to nested structure. + + Args: + data: The flattened dictionary + sep: The separator used in keys + + Returns: + Nested dictionary + """ + result = {} + + for key, value in data.items(): + parts = key.split(sep) + current = result + + for part in parts[:-1]: + if part not in current: + current[part] = {} + current = current[part] + + current[parts[-1]] = value + + return result + + +def load_yaml_file(file_path: str) -> Dict[str, Any]: + """ + Load a YAML file and return its contents. + + Args: + file_path: Path to the YAML file + + Returns: + Dictionary containing YAML data + + Raises: + SystemExit: If file not found or invalid YAML + """ + try: + with open(file_path, 'r') as f: + data = yaml.safe_load(f) or {} + + if not isinstance(data, dict): + click.echo(f"Error: Root must be a YAML mapping in {file_path}", err=True) + sys.exit(1) + + return data + except FileNotFoundError: + click.echo(f"Error: File not found: {file_path}", err=True) + sys.exit(1) + except yaml.YAMLError as e: + click.echo(f"Error: Invalid YAML in {file_path}: {e}", err=True) + sys.exit(1) + + +def make_hashable(value: Any) -> Any: + """ + Convert a value to a hashable type for set operations. + + Args: + value: The value to convert + + Returns: + A hashable representation of the value + """ + if isinstance(value, dict): + return tuple(sorted((k, make_hashable(v)) for k, v in value.items())) + elif isinstance(value, list): + return tuple(make_hashable(item) for item in value) + elif isinstance(value, set): + return frozenset(make_hashable(item) for item in value) + else: + return value + + +def perform_set_operation( + file1_data: Dict[str, Any], + file2_data: Dict[str, Any], + operation: str, + compare_mode: str, + depth: int +) -> Dict[str, Any]: + """ + Perform set operations on two dictionaries. + + Args: + file1_data: First file data + file2_data: Second file data + operation: One of 'union', 'intersect', 'diff', 'rdiff', 'symdiff' + compare_mode: Either 'keys' or 'kv' (key-value) + depth: Depth level for comparison (0 = unlimited) + + Returns: + Dictionary with the result of the set operation + """ + # Flatten both dictionaries + flat1 = flatten_dict(file1_data, depth) + flat2 = flatten_dict(file2_data, depth) + + if compare_mode == 'keys': + # Compare only keys + keys1 = set(flat1.keys()) + keys2 = set(flat2.keys()) + + if operation == 'union': + result_keys = keys1 | keys2 + elif operation == 'intersect': + result_keys = keys1 & keys2 + elif operation == 'diff': + result_keys = keys1 - keys2 + elif operation == 'rdiff': + result_keys = keys2 - keys1 + elif operation == 'symdiff': + result_keys = keys1 ^ keys2 + else: + result_keys = set() + + # Build result dictionary, preferring values from file1 + result = {} + for key in result_keys: + if key in flat1: + result[key] = flat1[key] + else: + result[key] = flat2[key] + + else: # compare_mode == 'kv' + # Compare key-value pairs using hashable representations + # Create sets of (key, hashable_value) tuples + items1 = set((k, make_hashable(v)) for k, v in flat1.items()) + items2 = set((k, make_hashable(v)) for k, v in flat2.items()) + + if operation == 'union': + # For union, we need to handle conflicts - file1 takes precedence + result_items = items1 | items2 + result = {} + for key, _ in result_items: + # Prefer file1 values + if key in flat1: + result[key] = flat1[key] + else: + result[key] = flat2[key] + elif operation == 'intersect': + result_items = items1 & items2 + result = {k: flat1[k] for k, _ in result_items} + elif operation == 'diff': + result_items = items1 - items2 + result = {k: flat1[k] for k, _ in result_items} + elif operation == 'rdiff': + result_items = items2 - items1 + result = {k: flat2[k] for k, _ in result_items} + elif operation == 'symdiff': + result_items = items1 ^ items2 + result = {} + for key, _ in result_items: + if key in flat1: + result[key] = flat1[key] + else: + result[key] = flat2[key] + else: + result = {} + + # Unflatten the result if depth > 1 + # For depth=1, we didn't flatten, so no need to unflatten + # For depth>1, we need to unflatten back to nested structure + if depth > 1: + result = unflatten_dict(result) + + return result + + @click.group() @click.version_option() def main(): - """config-utils: Capture environment variables and Django settings.""" + """config-utils: Capture environment variables, Django settings, and perform YAML set operations.""" pass @@ -161,5 +372,69 @@ def capture_django_settings(output, format, manage_py, settings): sys.exit(1) +# Set operation commands +def create_set_operation_command(operation: str, description: str): + """Factory function to create set operation commands.""" + @main.command(name=operation, help=description) + @click.argument('file1', type=click.Path(exists=True)) + @click.argument('file2', type=click.Path(exists=True)) + @click.option( + '--compare', + type=click.Choice(['keys', 'kv'], case_sensitive=False), + default='kv', + help='Comparison mode: keys or kv (key-values). Default: kv', + ) + @click.option( + '--depth', + type=int, + default=1, + help='How many levels deep to compare. 1 = root keys only, 0 = unlimited (full depth). Default: 1', + ) + def command(file1, file2, compare, depth): + try: + # Load YAML files + file1_data = load_yaml_file(file1) + file2_data = load_yaml_file(file2) + + # Perform set operation + result = perform_set_operation(file1_data, file2_data, operation, compare, depth) + + # Output result as YAML to stdout + yaml.dump(result, sys.stdout, default_flow_style=False, sort_keys=False, allow_unicode=True) + + except Exception as e: + click.echo(f"Error: {str(e)}", err=True) + sys.exit(1) + + return command + + +# Create all set operation commands +union_cmd = create_set_operation_command( + 'union', + 'Returns all keys (or key-value pairs) present in either file.' +) + +intersect_cmd = create_set_operation_command( + 'intersect', + 'Returns only keys (or key-value pairs) present in both files.' +) + +diff_cmd = create_set_operation_command( + 'diff', + 'Returns keys (or key-value pairs) in file1 but not in file2 (A - B).' +) + +rdiff_cmd = create_set_operation_command( + 'rdiff', + 'Returns keys (or key-value pairs) in file2 but not in file1 (B - A).' +) + +symdiff_cmd = create_set_operation_command( + 'symdiff', + 'Returns keys (or key-value pairs) in either file but not in both (symmetric difference).' +) + + if __name__ == '__main__': main() diff --git a/tools/config-utils/pyproject.toml b/tools/config-utils/pyproject.toml index 10bf057..e18f4e1 100644 --- a/tools/config-utils/pyproject.toml +++ b/tools/config-utils/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "config-utils" -version = "0.1.0" -description = "CLI tool for capturing environment variables and Django settings" +version = "0.2.0" +description = "CLI tool for capturing environment variables, Django settings, and performing YAML set operations" readme = "README.md" requires-python = ">=3.8" dependencies = [ From ad4c58c230e05a6f6276fbd26ad902eab40f230e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Jan 2026 09:16:12 +0000 Subject: [PATCH 2/3] Add comprehensive pytest tests for config-utils set operations Created a full test suite covering all YAML set operations functionality: Test Coverage: - 37 tests total, all passing with 75% code coverage - All 5 set operations: union, intersect, diff, rdiff, symdiff - Both comparison modes: keys and kv (key-value) - All depth levels: 0 (unlimited), 1 (root only), 2+ (nested) - Error handling: missing files, invalid YAML, non-dict roots - Edge cases: empty files, identical files, no matches - Helper functions: flatten_dict, unflatten_dict, make_hashable, etc. Test Organization: - tests/__init__.py: Test package initialization - tests/test_set_operations.py: Comprehensive test suite with 10 test classes Infrastructure: - Added pytest and pytest-cov as dev dependencies - Configured pytest in pyproject.toml with coverage settings - Updated README.md with testing instructions and examples - Uses temporary files for test fixtures to avoid pollution All tests verified passing with `python -m pytest -v --cov=cli` --- tools/config-utils/README.md | 34 +- tools/config-utils/pyproject.toml | 13 + tools/config-utils/tests/__init__.py | 1 + .../config-utils/tests/test_set_operations.py | 569 ++++++++++++++++++ 4 files changed, 615 insertions(+), 2 deletions(-) create mode 100644 tools/config-utils/tests/__init__.py create mode 100644 tools/config-utils/tests/test_set_operations.py diff --git a/tools/config-utils/README.md b/tools/config-utils/README.md index 084bf21..eba7eb9 100644 --- a/tools/config-utils/README.md +++ b/tools/config-utils/README.md @@ -271,15 +271,45 @@ uvx --from /path/to/config-utils config-utils capture-django-settings -m /path/t cd config-utils # Install in editable mode with development dependencies -pip install -e . +pip install -e ".[dev]" ``` +### Running Tests + +The project includes comprehensive pytest tests for all set operations functionality. + +```bash +# Run all tests +python -m pytest + +# Run with coverage report +python -m pytest --cov=cli --cov-report=term-missing + +# Run specific test class +python -m pytest tests/test_set_operations.py::TestUnionCommand -v + +# Run specific test +python -m pytest tests/test_set_operations.py::TestUnionCommand::test_union_kv_mode -v +``` + +### Test Coverage + +The test suite covers: +- All 5 set operations (union, intersect, diff, rdiff, symdiff) +- Both comparison modes (keys and kv) +- All depth levels (0, 1, 2+) +- Error handling (missing files, invalid YAML, non-dict roots) +- Edge cases (empty files, identical files, no matches) +- Helper functions (flatten_dict, unflatten_dict, make_hashable, perform_set_operation, load_yaml_file) + ### Project Structure ``` config-utils/ ├── cli.py -├── config_utils/ +├── tests/ +│ ├── __init__.py +│ └── test_set_operations.py ├── pyproject.toml └── README.md ``` diff --git a/tools/config-utils/pyproject.toml b/tools/config-utils/pyproject.toml index e18f4e1..5b7b433 100644 --- a/tools/config-utils/pyproject.toml +++ b/tools/config-utils/pyproject.toml @@ -13,8 +13,21 @@ dependencies = [ "pyyaml>=6.0", ] +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", +] + [project.scripts] config-utils = "cli:main" [tool.hatch.build.targets.wheel] only-include = ["cli.py"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --cov=cli --cov-report=term-missing" diff --git a/tools/config-utils/tests/__init__.py b/tools/config-utils/tests/__init__.py new file mode 100644 index 0000000..1d2ba84 --- /dev/null +++ b/tools/config-utils/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for config-utils.""" diff --git a/tools/config-utils/tests/test_set_operations.py b/tools/config-utils/tests/test_set_operations.py new file mode 100644 index 0000000..d73f864 --- /dev/null +++ b/tools/config-utils/tests/test_set_operations.py @@ -0,0 +1,569 @@ +"""Tests for YAML set operations.""" + +import pytest +import yaml +import tempfile +import os +from pathlib import Path +from click.testing import CliRunner +from cli import main, flatten_dict, unflatten_dict, perform_set_operation, load_yaml_file, make_hashable + + +@pytest.fixture +def runner(): + """Create a CLI runner.""" + return CliRunner() + + +@pytest.fixture +def temp_yaml_files(): + """Create temporary YAML files for testing.""" + files = {} + + # Simple file 1 + file1_data = { + 'database': 'postgres', + 'port': 5432, + 'debug': True + } + + # Simple file 2 + file2_data = { + 'database': 'postgres', + 'port': 3306, + 'logging': 'verbose' + } + + # Nested file 1 + nested1_data = { + 'database': { + 'host': 'localhost', + 'port': 5432 + }, + 'app': { + 'name': 'myapp' + } + } + + # Nested file 2 + nested2_data = { + 'database': { + 'host': 'localhost', + 'port': 3306 + }, + 'app': { + 'name': 'myapp' + } + } + + # Empty file + empty_data = {} + + # Identical to file 1 + identical_data = file1_data.copy() + + with tempfile.TemporaryDirectory() as tmpdir: + # Create file 1 + file1 = Path(tmpdir) / 'file1.yml' + with open(file1, 'w') as f: + yaml.dump(file1_data, f) + files['file1'] = str(file1) + + # Create file 2 + file2 = Path(tmpdir) / 'file2.yml' + with open(file2, 'w') as f: + yaml.dump(file2_data, f) + files['file2'] = str(file2) + + # Create nested file 1 + nested1 = Path(tmpdir) / 'nested1.yml' + with open(nested1, 'w') as f: + yaml.dump(nested1_data, f) + files['nested1'] = str(nested1) + + # Create nested file 2 + nested2 = Path(tmpdir) / 'nested2.yml' + with open(nested2, 'w') as f: + yaml.dump(nested2_data, f) + files['nested2'] = str(nested2) + + # Create empty file + empty = Path(tmpdir) / 'empty.yml' + with open(empty, 'w') as f: + yaml.dump(empty_data, f) + files['empty'] = str(empty) + + # Create identical file + identical = Path(tmpdir) / 'identical.yml' + with open(identical, 'w') as f: + yaml.dump(identical_data, f) + files['identical'] = str(identical) + + # Create invalid YAML file + invalid = Path(tmpdir) / 'invalid.yml' + with open(invalid, 'w') as f: + f.write('invalid: yaml: content:\n bad syntax') + files['invalid'] = str(invalid) + + # Create non-dict file (list) + list_file = Path(tmpdir) / 'list.yml' + with open(list_file, 'w') as f: + yaml.dump(['item1', 'item2'], f) + files['list'] = str(list_file) + + yield files + + +class TestFlattenDict: + """Test flatten_dict function.""" + + def test_flatten_depth_1(self): + """Test flattening with depth 1 (no flattening).""" + data = { + 'database': { + 'host': 'localhost', + 'port': 5432 + }, + 'app': { + 'name': 'myapp' + } + } + result = flatten_dict(data, depth=1) + assert result == data + + def test_flatten_depth_2(self): + """Test flattening with depth 2.""" + data = { + 'database': { + 'host': 'localhost', + 'port': 5432 + }, + 'app': { + 'name': 'myapp' + } + } + result = flatten_dict(data, depth=2) + expected = { + 'database.host': 'localhost', + 'database.port': 5432, + 'app.name': 'myapp' + } + assert result == expected + + def test_flatten_depth_0(self): + """Test flattening with depth 0 (unlimited).""" + data = { + 'level1': { + 'level2': { + 'level3': 'value' + } + } + } + result = flatten_dict(data, depth=0) + expected = { + 'level1.level2.level3': 'value' + } + assert result == expected + + +class TestUnflattenDict: + """Test unflatten_dict function.""" + + def test_unflatten_simple(self): + """Test unflattening a simple dict.""" + data = { + 'database.host': 'localhost', + 'database.port': 5432, + 'app.name': 'myapp' + } + result = unflatten_dict(data) + expected = { + 'database': { + 'host': 'localhost', + 'port': 5432 + }, + 'app': { + 'name': 'myapp' + } + } + assert result == expected + + +class TestMakeHashable: + """Test make_hashable function.""" + + def test_hashable_dict(self): + """Test making a dict hashable.""" + data = {'a': 1, 'b': 2} + result = make_hashable(data) + assert isinstance(result, tuple) + assert result == (('a', 1), ('b', 2)) + + def test_hashable_list(self): + """Test making a list hashable.""" + data = [1, 2, 3] + result = make_hashable(data) + assert isinstance(result, tuple) + assert result == (1, 2, 3) + + def test_hashable_nested(self): + """Test making nested structures hashable.""" + data = {'a': [1, 2], 'b': {'c': 3}} + result = make_hashable(data) + assert isinstance(result, tuple) + + +class TestUnionCommand: + """Test union command.""" + + def test_union_kv_mode(self, runner, temp_yaml_files): + """Test union with key-value comparison.""" + result = runner.invoke(main, [ + 'union', + temp_yaml_files['file1'], + temp_yaml_files['file2'] + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + assert output_data['database'] == 'postgres' + assert output_data['port'] == 5432 # file1 takes precedence + assert output_data['debug'] is True + assert output_data['logging'] == 'verbose' + + def test_union_keys_mode(self, runner, temp_yaml_files): + """Test union with keys-only comparison.""" + result = runner.invoke(main, [ + 'union', + temp_yaml_files['file1'], + temp_yaml_files['file2'], + '--compare', 'keys' + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + assert output_data['database'] == 'postgres' + assert output_data['port'] == 5432 # file1 value + assert output_data['debug'] is True + assert output_data['logging'] == 'verbose' + + +class TestIntersectCommand: + """Test intersect command.""" + + def test_intersect_kv_mode(self, runner, temp_yaml_files): + """Test intersect with key-value comparison.""" + result = runner.invoke(main, [ + 'intersect', + temp_yaml_files['file1'], + temp_yaml_files['file2'] + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + assert output_data == {'database': 'postgres'} + + def test_intersect_keys_mode(self, runner, temp_yaml_files): + """Test intersect with keys-only comparison.""" + result = runner.invoke(main, [ + 'intersect', + temp_yaml_files['file1'], + temp_yaml_files['file2'], + '--compare', 'keys' + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + assert output_data['database'] == 'postgres' + assert output_data['port'] == 5432 # file1 value + + def test_intersect_depth_1(self, runner, temp_yaml_files): + """Test intersect with depth 1 (root keys only).""" + result = runner.invoke(main, [ + 'intersect', + temp_yaml_files['nested1'], + temp_yaml_files['nested2'], + '--depth', '1', + '--compare', 'keys' + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + assert 'database' in output_data + assert 'app' in output_data + assert output_data['database']['port'] == 5432 # file1 value + + def test_intersect_depth_2(self, runner, temp_yaml_files): + """Test intersect with depth 2 (nested keys).""" + result = runner.invoke(main, [ + 'intersect', + temp_yaml_files['nested1'], + temp_yaml_files['nested2'], + '--depth', '2' + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + assert output_data == { + 'database': {'host': 'localhost'}, + 'app': {'name': 'myapp'} + } + + def test_intersect_depth_0(self, runner, temp_yaml_files): + """Test intersect with depth 0 (unlimited).""" + result = runner.invoke(main, [ + 'intersect', + temp_yaml_files['nested1'], + temp_yaml_files['nested2'], + '--depth', '0' + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + # Should have flattened keys + assert 'app.name' in output_data or 'app' in output_data + + +class TestDiffCommand: + """Test diff command.""" + + def test_diff_kv_mode(self, runner, temp_yaml_files): + """Test diff with key-value comparison.""" + result = runner.invoke(main, [ + 'diff', + temp_yaml_files['file1'], + temp_yaml_files['file2'] + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + assert output_data == {'port': 5432, 'debug': True} + + def test_diff_keys_mode(self, runner, temp_yaml_files): + """Test diff with keys-only comparison.""" + result = runner.invoke(main, [ + 'diff', + temp_yaml_files['file1'], + temp_yaml_files['file2'], + '--compare', 'keys' + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + assert output_data == {'debug': True} + + +class TestRdiffCommand: + """Test rdiff command.""" + + def test_rdiff_kv_mode(self, runner, temp_yaml_files): + """Test rdiff with key-value comparison.""" + result = runner.invoke(main, [ + 'rdiff', + temp_yaml_files['file1'], + temp_yaml_files['file2'] + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + assert output_data == {'port': 3306, 'logging': 'verbose'} + + def test_rdiff_keys_mode(self, runner, temp_yaml_files): + """Test rdiff with keys-only comparison.""" + result = runner.invoke(main, [ + 'rdiff', + temp_yaml_files['file1'], + temp_yaml_files['file2'], + '--compare', 'keys' + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + assert output_data == {'logging': 'verbose'} + + +class TestSymdiffCommand: + """Test symdiff command.""" + + def test_symdiff_kv_mode(self, runner, temp_yaml_files): + """Test symdiff with key-value comparison.""" + result = runner.invoke(main, [ + 'symdiff', + temp_yaml_files['file1'], + temp_yaml_files['file2'] + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + # Should have port from both files, debug, and logging + assert 'debug' in output_data + assert 'logging' in output_data + assert 'port' in output_data + + def test_symdiff_keys_mode(self, runner, temp_yaml_files): + """Test symdiff with keys-only comparison.""" + result = runner.invoke(main, [ + 'symdiff', + temp_yaml_files['file1'], + temp_yaml_files['file2'], + '--compare', 'keys' + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + # Only keys that are not in both + assert output_data == {'debug': True, 'logging': 'verbose'} + + +class TestErrorHandling: + """Test error handling.""" + + def test_file_not_found(self, runner, temp_yaml_files): + """Test error when file is not found.""" + result = runner.invoke(main, [ + 'intersect', + '/nonexistent/file.yml', + temp_yaml_files['file1'] + ]) + assert result.exit_code == 2 # Click's error code for invalid argument + + def test_invalid_yaml(self, runner, temp_yaml_files): + """Test error when YAML is invalid.""" + result = runner.invoke(main, [ + 'intersect', + temp_yaml_files['invalid'], + temp_yaml_files['file1'] + ]) + assert result.exit_code == 1 + assert 'Error' in result.output + + def test_non_dict_root(self, runner, temp_yaml_files): + """Test error when root is not a dict.""" + result = runner.invoke(main, [ + 'intersect', + temp_yaml_files['list'], + temp_yaml_files['file1'] + ]) + assert result.exit_code == 1 + assert 'Root must be a YAML mapping' in result.output + + +class TestEdgeCases: + """Test edge cases.""" + + def test_empty_file(self, runner, temp_yaml_files): + """Test with empty file.""" + result = runner.invoke(main, [ + 'union', + temp_yaml_files['empty'], + temp_yaml_files['file1'] + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + # Should return all from file1 + assert output_data['database'] == 'postgres' + + def test_identical_files_intersect(self, runner, temp_yaml_files): + """Test intersect with identical files.""" + result = runner.invoke(main, [ + 'intersect', + temp_yaml_files['file1'], + temp_yaml_files['identical'] + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + # Should return full file + assert output_data['database'] == 'postgres' + assert output_data['port'] == 5432 + assert output_data['debug'] is True + + def test_identical_files_diff(self, runner, temp_yaml_files): + """Test diff with identical files.""" + result = runner.invoke(main, [ + 'diff', + temp_yaml_files['file1'], + temp_yaml_files['identical'] + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + # Should return empty or None + assert output_data == {} or output_data is None + + def test_no_matches_intersect(self, runner, temp_yaml_files): + """Test intersect with no matching keys.""" + # Create a file with completely different keys + with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as f: + yaml.dump({'different': 'value', 'other': 'data'}, f) + different_file = f.name + + try: + result = runner.invoke(main, [ + 'intersect', + temp_yaml_files['file1'], + different_file + ]) + assert result.exit_code == 0 + output_data = yaml.safe_load(result.output) + # Should return empty + assert output_data == {} or output_data is None + finally: + os.unlink(different_file) + + +class TestLoadYamlFile: + """Test load_yaml_file function.""" + + def test_load_valid_file(self, temp_yaml_files): + """Test loading a valid YAML file.""" + data = load_yaml_file(temp_yaml_files['file1']) + assert data['database'] == 'postgres' + assert data['port'] == 5432 + + def test_load_empty_file(self, temp_yaml_files): + """Test loading an empty YAML file.""" + data = load_yaml_file(temp_yaml_files['empty']) + assert data == {} + + def test_load_nonexistent_file(self): + """Test loading a nonexistent file.""" + with pytest.raises(SystemExit) as exc_info: + load_yaml_file('/nonexistent/file.yml') + assert exc_info.value.code == 1 + + def test_load_invalid_yaml(self, temp_yaml_files): + """Test loading an invalid YAML file.""" + with pytest.raises(SystemExit) as exc_info: + load_yaml_file(temp_yaml_files['invalid']) + assert exc_info.value.code == 1 + + def test_load_non_dict_root(self, temp_yaml_files): + """Test loading a YAML file with non-dict root.""" + with pytest.raises(SystemExit) as exc_info: + load_yaml_file(temp_yaml_files['list']) + assert exc_info.value.code == 1 + + +class TestPerformSetOperation: + """Test perform_set_operation function.""" + + def test_union_operation(self): + """Test union operation.""" + file1 = {'a': 1, 'b': 2} + file2 = {'b': 3, 'c': 4} + result = perform_set_operation(file1, file2, 'union', 'kv', 1) + assert result == {'a': 1, 'b': 2, 'c': 4} + + def test_intersect_operation(self): + """Test intersect operation.""" + file1 = {'a': 1, 'b': 2, 'c': 3} + file2 = {'b': 2, 'c': 4, 'd': 5} + result = perform_set_operation(file1, file2, 'intersect', 'kv', 1) + assert result == {'b': 2} + + def test_diff_operation(self): + """Test diff operation.""" + file1 = {'a': 1, 'b': 2, 'c': 3} + file2 = {'b': 2, 'c': 4} + result = perform_set_operation(file1, file2, 'diff', 'kv', 1) + assert result == {'a': 1, 'c': 3} + + def test_rdiff_operation(self): + """Test rdiff operation.""" + file1 = {'a': 1, 'b': 2} + file2 = {'b': 2, 'c': 4} + result = perform_set_operation(file1, file2, 'rdiff', 'kv', 1) + assert result == {'c': 4} + + def test_symdiff_operation(self): + """Test symdiff operation.""" + file1 = {'a': 1, 'b': 2} + file2 = {'b': 2, 'c': 4} + result = perform_set_operation(file1, file2, 'symdiff', 'kv', 1) + assert result == {'a': 1, 'c': 4} From ea7732a471fa3441115cf17009aec99396828d91 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Jan 2026 09:45:25 +0000 Subject: [PATCH 3/3] Update GitHub Actions workflow to test all tools Changed the workflow from testing only locust-compare to testing all tools in the repository using a matrix strategy. Changes: - Added matrix strategy with all tools: locust-compare, config-utils - Set fail-fast: false to ensure all tools are tested even if one fails - Updated step names to be dynamic based on matrix.tool - Both tools now run tests in parallel as separate jobs This ensures comprehensive testing across the entire repository. --- .github/workflows/test.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c4c8056..4ee68fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,12 @@ on: jobs: test: runs-on: ubuntu-latest + strategy: + matrix: + tool: + - locust-compare + - config-utils + fail-fast: false steps: - uses: actions/checkout@v4 @@ -17,9 +23,10 @@ jobs: with: python-version: 3.12 - - name: Install and test locust-compare - working-directory: tools/locust-compare + - name: Install and test ${{ matrix.tool }} + working-directory: tools/${{ matrix.tool }} run: | python -m pip install --upgrade pip pip install -e .[dev] - pytest + python -m pytest -v +