diff --git a/.github/workflows/pip.yml b/.github/workflows/pip.yml index a6c2ac7..f488aab 100644 --- a/.github/workflows/pip.yml +++ b/.github/workflows/pip.yml @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: platform: [windows-latest, macos-latest, ubuntu-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 diff --git a/pyproject.toml b/pyproject.toml index 0d32e53..08f0f84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ 'gitpython>=3.1.43', 'msgspec>=0.19.0', 'packaging', + 'pyyaml', ] description = "ANTLR4 grammars for McStas and McXtrace" readme = "README.md" @@ -30,11 +31,11 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dynamic = ["version"] diff --git a/src/mccode_antlr/cli/management.py b/src/mccode_antlr/cli/management.py index 6c65363..a43996e 100644 --- a/src/mccode_antlr/cli/management.py +++ b/src/mccode_antlr/cli/management.py @@ -36,23 +36,103 @@ def config_get(key, verbose): print(config_dump(d)) -def config_set(key, value): - from mccode_antlr.config import config as c - levels = key.split('.') - for level in levels: - c = c[level] - c.set(value) +def _get_config_yaml(path: str | None = None): + from pathlib import Path + from yaml import safe_load + from mccode_antlr.config import config + config_file = Path(path or config.config_dir()) / 'config.yaml' + if config_file.exists(): + with config_file.open('r') as f: + return safe_load(f) or {} + return {} -def config_unset(key): - from mccode_antlr.config import config as c + +def _save_config_yaml(config_dict, path: str | None = None): + from pathlib import Path + from mccode_antlr.config import config + + config_file = Path(path or config.config_dir()) / 'config.yaml' + config_file.parent.mkdir(parents=True, exist_ok=True) + with config_file.open('w') as f: + f.write(config_dump(config_dict)) + + +def config_set(key, value, path: str | None = None): + """ + Set a configuration value both in the file and in memory. + This function updates the existing configuration file to preserve + other keys and only modify the specified key. + """ + from mccode_antlr.config import config + from pathlib import Path + # Load existing file config if it exists + existing_config = _get_config_yaml(path) + + # Navigate to the nested key and set the value + levels = key.split('.') + try: + current = existing_config + for level in levels[:-1]: + if level not in current: + current[level] = {} + current = current[level] + current[levels[-1]] = value + except (KeyError, TypeError): + print(f"Error setting key {key} in configuration.") + return + + # Save the complete config + _save_config_yaml(existing_config, path) + + # Also update the in-memory config if we're not modifying a different path + if path is None or Path(path) == Path(config.config_dir()): + try: + c = config + for level in levels[:-1]: + c = c[level] + c[levels[-1]] = value + except (KeyError, TypeError): + print(f"Error setting key {key} in in-memory configuration.") + return + + +def config_unset(key, path: str | None = None): + """ + Unset a configuration value both in the file and in memory. + This function updates the existing configuration file to preserve + other keys and only remove the specified key. + """ + from mccode_antlr.config import config + from pathlib import Path + existing_config = _get_config_yaml(path) + + # Navigate to the nested key and delete it levels = key.split('.') - for level in levels: - c = c[level] - del c + current = existing_config + try: + for level in levels[:-1]: + current = current[level] + del current[levels[-1]] + except (KeyError, TypeError): + # Key doesn't exist in file config, that's okay + pass + + # Save the complete config + _save_config_yaml(existing_config, path) + + # Also update the in-memory config if we're not modifying a different path + if path is None or Path(path) == Path(config.config_dir()): + try: + c = config + for level in levels[:-1]: + c = c[level] + del c[levels[-1]] + except (KeyError, TypeError): + pass -def config_save(path, verbose): +def config_save(path: str | None = None, verbose: bool = False): from pathlib import Path from mccode_antlr.config import config as c config_dir = Path(path or c.config_dir()) @@ -79,10 +159,12 @@ def add_config_management_parser(modes): s = actions.add_parser(name='set', help='Update or insert one configuration value') s.add_argument('key', type=str, default=None) s.add_argument('value', type=str, default=None) + s.add_argument('path', type=str, nargs='?') s.set_defaults(action=config_set) u = actions.add_parser(name='unset', help='Remove one configuration value') u.add_argument('key', type=str, default=None) + u.add_argument('path', type=str, nargs='?') u.set_defaults(action=config_unset) v = actions.add_parser(name='save', help='Create or update the configuration file') diff --git a/src/mccode_antlr/reader/registry.py b/src/mccode_antlr/reader/registry.py index c7507a8..3034677 100644 --- a/src/mccode_antlr/reader/registry.py +++ b/src/mccode_antlr/reader/registry.py @@ -8,6 +8,40 @@ from mccode_antlr import Flavor from typing import Type, Any from msgspec import Struct +import requests +from time import sleep + + +def _fetch_registry_with_retry(url, max_retries=3, timeout=10): + """Fetch a registry file with retry logic for gateway timeouts""" + for attempt in range(max_retries): + try: + r = requests.get(url, timeout=timeout) + if r.ok: + return r + # If we get a gateway timeout (502, 503, 504), retry + if r.status_code in (502, 503, 504) and attempt < max_retries - 1: + wait_time = 2 ** attempt # exponential backoff: 1s, 2s, 4s + logger.warning(f"Gateway timeout ({r.status_code}), retrying in {wait_time}s...") + sleep(wait_time) + continue + return r + except requests.exceptions.Timeout: + if attempt < max_retries - 1: + wait_time = 2 ** attempt + logger.warning(f"Request timeout, retrying in {wait_time}s...") + sleep(wait_time) + continue + raise + except requests.exceptions.RequestException as e: + if attempt < max_retries - 1: + wait_time = 2 ** attempt + logger.warning(f"Request failed: {e}, retrying in {wait_time}s...") + sleep(wait_time) + continue + raise + + raise RuntimeError(f"Failed to retrieve {url} after {max_retries} attempts") def ensure_regex_pattern(pattern): @@ -250,11 +284,10 @@ def __init__(self, name: str, url: str, version: str, filename: str | None = Non with registry_file_path.open('r') as file: registry = {k: v for k, v in [x.strip().split(maxsplit=1) for x in file.readlines() if len(x)]} else: - import requests # We allow a full-dictionary to be provided, otherwise we expect the registry file to be available from the # base_url where all subsequent files are also expected to be available if not isinstance(registry, dict): - r = requests.get((registry or base_url) + registry_file) + r = _fetch_registry_with_retry((registry or base_url) + registry_file) if not r.ok: raise RuntimeError(f"Could not retrieve {r.url} because {r.reason}") registry = {k: v for k, v in [x.split(maxsplit=1) for x in r.text.split('\n') if len(x)]} diff --git a/tests/test_cli_management.py b/tests/test_cli_management.py new file mode 100644 index 0000000..453091a --- /dev/null +++ b/tests/test_cli_management.py @@ -0,0 +1,455 @@ +""" +Pytest tests for CLI management functions. + +UPDATE: The deficiencies in config_set and config_unset have been FIXED! + +These tests verify the functionality of config and cache management commands in +src/mccode_antlr/cli/management.py. + +ORIGINAL ISSUE (NOW RESOLVED): +Previously, config_set and config_unset only modified in-memory configuration +without persisting changes to disk, causing data loss and user confusion. + +FIXES IMPLEMENTED: +1. config_set() NOW properly persists: + - Reads existing config file + - Merges new value into existing structure + - Saves complete config back to disk + - Preserves all other keys + +2. config_unset() NOW properly persists: + - Reads existing config file + - Removes only the specified key + - Saves complete config back to disk + - Preserves all other keys + +The functions now match user expectations and prevent data loss! +""" +import pytest +from pathlib import Path +from tempfile import TemporaryDirectory +import yaml +import confuse + + +class TestConfigManagement: + """Test suite for configuration management functions.""" + + @pytest.fixture + def temp_config_dir(self): + """Create a temporary directory for config files.""" + with TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + @pytest.fixture + def isolated_config(self, monkeypatch, temp_config_dir): + """Create an isolated config instance for testing.""" + # Create a test config that won't interfere with the real one + test_config = confuse.Configuration('mccodeantlr_test', read=False) + + # Set up initial test data + test_data = { + 'test_key': 'test_value', + 'nested': { + 'key1': 'value1', + 'key2': 'value2' + }, + 'compiler': { + 'flags': '-O2' + } + } + test_config.set(test_data) + + # Override config_dir method to use temp directory + test_config.config_dir = lambda: str(temp_config_dir) + + # We'll manually inject this into functions that need it + return test_config, temp_config_dir + + def test_config_dump(self): + """Test that config_dump properly serializes configuration to YAML.""" + from mccode_antlr.cli.management import config_dump + from collections import OrderedDict + + test_config = OrderedDict([ + ('key1', 'value1'), + ('key2', {'nested': 'value'}) + ]) + + result = config_dump(test_config) + + assert isinstance(result, str) + assert 'key1: value1' in result + assert 'key2:' in result + assert 'nested: value' in result + + def test_config_list_displays_real_config(self, capsys): + """Test that config_list prints configuration values from the real config.""" + from mccode_antlr.cli.management import config_list + + config_list(regex=None) + + captured = capsys.readouterr() + # The real config should have some standard keys + assert len(captured.out) > 0 + # Should be valid YAML output + assert ':' in captured.out + + def test_config_get_full_real_config(self, capsys): + """Test config_get without key returns full configuration.""" + from mccode_antlr.cli.management import config_get + + config_get(key=None, verbose=False) + + captured = capsys.readouterr() + # Should output something + assert len(captured.out) > 0 + + def test_config_get_nonexistent_key_verbose(self, capsys): + """Test config_get with nonexistent key and verbose mode.""" + from mccode_antlr.cli.management import config_get + + config_get(key='this_key_definitely_does_not_exist_12345', verbose=True) + + captured = capsys.readouterr() + assert 'not found' in captured.out.lower() + + def test_config_get_nonexistent_key_silent(self, capsys): + """Test config_get with nonexistent key silently returns.""" + from mccode_antlr.cli.management import config_get + + config_get(key='this_key_definitely_does_not_exist_12345', verbose=False) + + captured = capsys.readouterr() + assert captured.out == '' + + def test_config_set_now_persists_to_disk(self, temp_config_dir): + """ + FIXED: Verify that config_set NOW properly persists changes to disk. + + This test verifies that the deficiency has been fixed - config_set + now reads the existing config file, merges changes, and saves back + the complete configuration. + """ + from mccode_antlr.cli.management import config_set + + config_file = temp_config_dir / 'config.yaml' + + # Create an initial config file with multiple keys + initial_config = { + 'key1': 'value1', + 'nested': { + 'key2': 'value2', + 'key3': 'value3' + } + } + with config_file.open('w') as f: + yaml.dump(initial_config, f) + + # Use config_set to change a nested value + config_set('nested.key2', 'modified_value', str(temp_config_dir)) + + # Read the file to verify persistence + with config_file.open('r') as f: + saved_config = yaml.safe_load(f) + + # FIXED: All keys should be preserved, and the change should be saved + assert saved_config['key1'] == 'value1', "key1 should be preserved!" + assert saved_config['nested']['key2'] == 'modified_value', "nested.key2 should be updated!" + assert saved_config['nested']['key3'] == 'value3', "nested.key3 should be preserved!" + + def test_config_set_creates_nested_structure(self, temp_config_dir): + """ + Test that config_set properly creates nested structure when setting new keys. + """ + from mccode_antlr.cli.management import config_set + + config_file = temp_config_dir / 'config.yaml' + + # Start with simple config + initial_config = {'existing': 'value'} + with config_file.open('w') as f: + yaml.dump(initial_config, f) + + # Add a deeply nested key + config_set('new.nested.deep.key', 'deep_value', str(temp_config_dir)) + + # Verify the structure was created and existing keys preserved + with config_file.open('r') as f: + saved_config = yaml.safe_load(f) + + assert saved_config['existing'] == 'value', "Existing key should be preserved!" + assert saved_config['new']['nested']['deep']['key'] == 'deep_value', "Nested structure should be created!" + + def test_config_unset_now_persists_deletion(self, temp_config_dir): + """ + FIXED: Verify that config_unset NOW properly persists deletions to disk. + + The deficiency has been fixed - config_unset now reads the existing + config file, removes the specified key, and saves back the complete + configuration with other keys preserved. + """ + from mccode_antlr.cli.management import config_unset + + config_file = temp_config_dir / 'config.yaml' + + # Create initial config + initial_config = { + 'keep_me': 'value', + 'nested': { + 'remove_me': 'gone', + 'keep_me_too': 'preserved' + } + } + with config_file.open('w') as f: + yaml.dump(initial_config, f) + + # Unset a nested key + config_unset('nested.remove_me', str(temp_config_dir)) + + # Verify the deletion was persisted and other keys preserved + with config_file.open('r') as f: + saved_config = yaml.safe_load(f) + + # FIXED: Key should be removed from file, others preserved + assert saved_config['keep_me'] == 'value', "Top-level key should be preserved!" + assert 'remove_me' not in saved_config['nested'], "nested.remove_me should be deleted!" + assert saved_config['nested']['keep_me_too'] == 'preserved', "nested.keep_me_too should be preserved!" + + def test_config_save_creates_file(self, temp_config_dir): + """Test that config_save creates a config file.""" + from mccode_antlr.cli.management import config_save + + config_file = temp_config_dir / 'config.yaml' + assert not config_file.exists() + + config_save(path=str(temp_config_dir), verbose=False) + + assert config_file.exists() + + def test_config_save_writes_yaml(self, temp_config_dir): + """Test that config_save writes valid YAML.""" + from mccode_antlr.cli.management import config_save + + config_save(path=str(temp_config_dir), verbose=False) + + config_file = temp_config_dir / 'config.yaml' + with config_file.open('r') as f: + saved_config = yaml.safe_load(f) + + # Should be a dictionary + assert isinstance(saved_config, dict) + # Should have some content from the real config + assert len(saved_config) > 0 + + def test_config_save_verbose_output(self, capsys, temp_config_dir): + """Test that config_save with verbose prints confirmation.""" + from mccode_antlr.cli.management import config_save + + config_save(path=str(temp_config_dir), verbose=True) + + captured = capsys.readouterr() + assert 'Configuration written to' in captured.out + assert 'config.yaml' in captured.out + + +class TestCacheManagement: + """Test suite for cache management functions.""" + + @pytest.fixture + def temp_cache(self, monkeypatch): + """Create a temporary cache directory.""" + with TemporaryDirectory() as tmpdir: + cache_path = Path(tmpdir) + + # Mock the cache_path function + from mccode_antlr.cli import management + monkeypatch.setattr(management, 'cache_path', lambda: cache_path) + + # Create some test cache structure + (cache_path / 'package1' / 'v1.0').mkdir(parents=True) + (cache_path / 'package1' / 'v2.0').mkdir(parents=True) + (cache_path / 'package2' / 'v1.5').mkdir(parents=True) + + # Add some dummy files + (cache_path / 'package1' / 'v1.0' / 'data.txt').write_text('test') + (cache_path / 'package2' / 'v1.5' / 'data.txt').write_text('test') + + yield cache_path + + def test_cache_path_returns_path(self): + """Test that cache_path returns a valid path.""" + from mccode_antlr.cli.management import cache_path + + path = cache_path() + + assert isinstance(path, (str, Path)) + assert 'mccodeantlr' in str(path).lower() + + def test_cache_list_all(self, capsys, temp_cache): + """Test cache_list without name shows all packages.""" + from mccode_antlr.cli.management import cache_list + + cache_list(name=None, long=False) + + captured = capsys.readouterr() + assert 'package1' in captured.out + assert 'package2' in captured.out + + def test_cache_list_specific_package(self, capsys, temp_cache): + """Test cache_list with package name shows versions.""" + from mccode_antlr.cli.management import cache_list + + cache_list(name='package1', long=False) + + captured = capsys.readouterr() + assert 'v1.0' in captured.out or 'package1/v1.0' in captured.out + assert 'v2.0' in captured.out or 'package1/v2.0' in captured.out + + def test_cache_list_long_format(self, capsys, temp_cache): + """Test cache_list with long format shows full paths.""" + from mccode_antlr.cli.management import cache_list + + cache_list(name='package1', long=True) + + captured = capsys.readouterr() + # In long format, we expect to see the full path + assert 'package1' in captured.out + + def test_cache_remove_specific_version_with_force(self, temp_cache): + """Test removing a specific version with force flag.""" + from mccode_antlr.cli.management import cache_remove + + version_path = temp_cache / 'package1' / 'v1.0' + assert version_path.exists() + + cache_remove(name='package1', version='v1.0', force=True) + + assert not version_path.exists() + # Other versions should still exist + assert (temp_cache / 'package1' / 'v2.0').exists() + + def test_cache_remove_all_versions_with_force(self, temp_cache): + """Test removing all versions of a package.""" + from mccode_antlr.cli.management import cache_remove + + package_path = temp_cache / 'package1' + assert package_path.exists() + + cache_remove(name='package1', version=None, force=True) + + assert not package_path.exists() + # Other packages should still exist + assert (temp_cache / 'package2').exists() + + def test_cache_remove_all_with_force(self, temp_cache): + """Test removing entire cache.""" + from mccode_antlr.cli.management import cache_remove + + assert temp_cache.exists() + assert (temp_cache / 'package1').exists() + assert (temp_cache / 'package2').exists() + + cache_remove(name=None, version=None, force=True) + + # The entire cache directory should be removed + assert not temp_cache.exists() + + def test_cache_remove_without_force_requires_confirmation(self, temp_cache, monkeypatch): + """Test that cache_remove without force asks for confirmation.""" + from mccode_antlr.cli.management import cache_remove + + # Mock input to decline removal + monkeypatch.setattr('builtins.input', lambda _: 'n') + + version_path = temp_cache / 'package1' / 'v1.0' + assert version_path.exists() + + cache_remove(name='package1', version='v1.0', force=False) + + # Should still exist because we declined + assert version_path.exists() + + def test_cache_remove_with_yes_confirmation(self, temp_cache, monkeypatch): + """Test that cache_remove accepts 'yes' confirmation.""" + from mccode_antlr.cli.management import cache_remove + + # Mock input to confirm removal + monkeypatch.setattr('builtins.input', lambda _: 'yes') + + version_path = temp_cache / 'package1' / 'v1.0' + assert version_path.exists() + + cache_remove(name='package1', version='v1.0', force=False) + + # Should be removed because we confirmed + assert not version_path.exists() + + def test_cache_remove_with_y_confirmation(self, temp_cache, monkeypatch): + """Test that cache_remove accepts 'y' confirmation.""" + from mccode_antlr.cli.management import cache_remove + + # Mock input to confirm removal with short form + monkeypatch.setattr('builtins.input', lambda _: 'y') + + version_path = temp_cache / 'package1' / 'v1.0' + assert version_path.exists() + + cache_remove(name='package1', version='v1.0', force=False) + + assert not version_path.exists() + + +class TestParserFunctions: + """Test the argument parser creation functions.""" + + def test_config_management_parser_structure(self): + """Test that the config parser is properly structured.""" + from mccode_antlr.cli.management import add_config_management_parser + from argparse import ArgumentParser + + parser = ArgumentParser() + modes = parser.add_subparsers() + actions = add_config_management_parser(modes) + + # Verify that the actions subparser was returned + assert actions is not None + + def test_cache_management_parser_structure(self): + """Test that the cache parser is properly structured.""" + from mccode_antlr.cli.management import add_cache_management_parser + from argparse import ArgumentParser + + parser = ArgumentParser() + modes = parser.add_subparsers() + actions = add_cache_management_parser(modes) + + assert actions is not None + + def test_mccode_management_parser_creation(self): + """Test that the main management parser can be created.""" + from mccode_antlr.cli.management import mccode_management_parser + + parser = mccode_management_parser() + + assert parser is not None + assert parser.prog == "mccode-antlr" + + def test_mccode_management_parser_has_subcommands(self): + """Test that parser has config and cache subcommands.""" + from mccode_antlr.cli.management import mccode_management_parser + + parser = mccode_management_parser() + + # Try parsing config subcommand + args = parser.parse_args(['config', 'list']) + assert hasattr(args, 'action') + + # Try parsing cache subcommand + args = parser.parse_args(['cache', 'list']) + assert hasattr(args, 'action') + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) + diff --git a/tests/test_config_unset_issue.py b/tests/test_config_unset_issue.py new file mode 100644 index 0000000..39b44ff --- /dev/null +++ b/tests/test_config_unset_issue.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +Test to verify config_unset preserves other keys. +""" +import tempfile +from pathlib import Path +import yaml + + +def test_config_unset_preserves_keys(): + """Verify that config_unset only removes the target key and preserves others.""" + from mccode_antlr.cli.management import config_unset + + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + + # Manually create an initial config with nested structure + config_file = tmppath / 'config.yaml' + initial_config = { + 'compiler': { + 'cc': 'gcc', + 'flags': '-O2', + 'debug': False + }, + 'runtime': { + 'threads': 4, + 'verbose': True + } + } + with config_file.open('w') as f: + yaml.dump(initial_config, f) + + print("Initial config:") + print(yaml.dump(initial_config)) + + # Now use config_unset to remove a nested key + config_unset('compiler.debug', str(tmppath)) + + # Read back the saved config + with config_file.open('r') as f: + saved_config = yaml.safe_load(f) + + print("\nSaved config after unsetting compiler.debug:") + print(yaml.dump(saved_config)) + + # Check if other keys are preserved + print("\nChecking preserved keys:") + print(f" compiler.cc: {'✓' if 'cc' in saved_config.get('compiler', {}) else '✗ MISSING'}") + print(f" compiler.flags: {'✓' if 'flags' in saved_config.get('compiler', {}) else '✗ MISSING'}") + print(f" compiler.debug: {'✗ REMOVED' if 'debug' not in saved_config.get('compiler', {}) else '✓ (should be removed)'}") + print(f" runtime.threads: {'✓' if 'threads' in saved_config.get('runtime', {}) else '✗ MISSING'}") + print(f" runtime.verbose: {'✓' if 'verbose' in saved_config.get('runtime', {}) else '✗ MISSING'}") + + # Verify the expected behavior + assert 'compiler' in saved_config, "compiler section missing!" + assert 'runtime' in saved_config, "runtime section missing!" + assert 'debug' not in saved_config['compiler'], "debug key should be removed!" + assert saved_config['compiler']['cc'] == 'gcc', "cc was lost!" + assert saved_config['compiler']['flags'] == '-O2', "flags was lost!" + assert saved_config['runtime']['threads'] == 4, "runtime.threads was lost!" + assert saved_config['runtime']['verbose'] == True, "runtime.verbose was lost!" + + +def test_config_unset_entire_section(): + """Test removing an entire section.""" + from mccode_antlr.cli.management import config_unset + + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + + config_file = tmppath / 'config.yaml' + initial_config = { + 'compiler': { + 'cc': 'gcc', + 'flags': '-O2' + }, + 'runtime': { + 'threads': 4 + } + } + with config_file.open('w') as f: + yaml.dump(initial_config, f) + + print("\n" + "="*60) + print("Test: Unsetting entire section") + print("="*60) + print("\nInitial config:") + print(yaml.dump(initial_config)) + + # Remove entire compiler section + config_unset('compiler', str(tmppath)) + + with config_file.open('r') as f: + saved_config = yaml.safe_load(f) + + print("\nSaved config after unsetting compiler:") + print(yaml.dump(saved_config)) + + assert 'compiler' not in saved_config, "compiler section should be removed!" + assert 'runtime' in saved_config, "runtime section was lost!" + assert saved_config['runtime']['threads'] == 4, "runtime.threads was lost!" + + +if __name__ == '__main__': + test_config_unset_preserves_keys() + print("\n✅ Test 1 passed!") + + test_config_unset_entire_section() + print("\n✅ Test 2 passed!") + + print("\n" + "="*60) + print("All tests passed!") + print("="*60) + diff --git a/tests/test_nested_key_issue.py b/tests/test_nested_key_issue.py new file mode 100644 index 0000000..92cb5ce --- /dev/null +++ b/tests/test_nested_key_issue.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Test to reproduce the nested key save issue. +""" +import tempfile +from pathlib import Path +import yaml + + +def test_nested_key_save_issue(): + """Reproduce the issue where setting a nested key only saves that key.""" + from mccode_antlr.cli.management import config_set + + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + + # Manually create an initial config with nested structure + config_file = tmppath / 'config.yaml' + initial_config = { + 'compiler': { + 'cc': 'gcc', + 'flags': '-O2', + 'debug': False + }, + 'runtime': { + 'threads': 4 + } + } + with config_file.open('w') as f: + yaml.dump(initial_config, f) + + print("Initial config:") + print(yaml.dump(initial_config)) + + # Now use config_set to update a nested key + config_set('compiler.flags', '-O3', str(tmppath)) + + # Read back the saved config + with config_file.open('r') as f: + saved_config = yaml.safe_load(f) + + print("\nSaved config after setting compiler.flags to -O3:") + print(yaml.dump(saved_config)) + + # Check if all keys are preserved + print("\nChecking preserved keys:") + print(f" compiler.cc: {'✓' if 'cc' in saved_config.get('compiler', {}) else '✗ MISSING'}") + print(f" compiler.flags: {'✓' if 'flags' in saved_config.get('compiler', {}) else '✗ MISSING'}") + print(f" compiler.debug: {'✓' if 'debug' in saved_config.get('compiler', {}) else '✗ MISSING'}") + print(f" runtime.threads: {'✓' if 'threads' in saved_config.get('runtime', {}) else '✗ MISSING'}") + + # Verify the issue + assert 'compiler' in saved_config, "compiler section missing!" + assert 'runtime' in saved_config, "runtime section missing!" + assert saved_config['compiler']['flags'] == '-O3', "flags not updated!" + assert saved_config['compiler']['cc'] == 'gcc', "cc was lost!" + assert saved_config['runtime']['threads'] == 4, "runtime section was lost!" + + +if __name__ == '__main__': + test_nested_key_save_issue() + print("\n✅ Test passed!") +