Source code for aiida_vasp.commands.option_parser

"""
Module for reading options for cmd interface.

This module provides the :class:`OptionParser` class for parsing command-line options
in various formats including hierarchical dot notation, JSON, and YAML files.

**Key Features:**

* Hierarchical dot notation parsing (e.g., ``incar.elem=1,options.resources.num_machines=2``)
* Automatic type conversion (int, float, bool, None, str)
* JSON and YAML file loading support
* Backward compatibility functions

**Example Usage:**

.. code-block:: python

    from aiida_vasp.commands.option_parser import OptionParser

    # Parse hierarchical settings
    result = OptionParser.parse_hierarchical_dict("incar.elem=1,debug.enabled=true,solver.method=None")
    # Returns: {'incar': {'elem': 1}, 'debug': {'enabled': True}, 'solver': {'method': None}}

    # Process various option formats
    result = OptionParser.process_dict_option("config.json")  # Load from file
    result = OptionParser.process_dict_option('{"key": "value"}')  # Parse JSON
    result = OptionParser.process_dict_option("key=value,nested.key=123")  # Parse hierarchical
"""

from __future__ import annotations

import json
from collections import defaultdict
from pathlib import Path

import yaml


[docs] class OptionParser: """Parser for command-line options supporting various formats including hierarchical dot notation."""
[docs] @staticmethod def nested_dict(): """Create a nested defaultdict that automatically creates missing levels. :return: A nested defaultdict instance :rtype: defaultdict """ return defaultdict(OptionParser.nested_dict)
[docs] @classmethod def process_dict_option(cls, value: str | None) -> dict: """Process an option that can be a JSON string or a file path. :param value: String value that can be JSON, file path, or hierarchical key=value pairs :type value: str or None :return: Parsed dictionary from the input value :rtype: dict """ if value is None: return {} if value.lower().endswith(('.json', '.yaml', '.yml')): return cls._load_dict_from_file(value) return cls._parse_text_as_dict(value)
[docs] @classmethod def parse_hierarchical_dict(cls, settings_str: str) -> dict: """Parse hierarchical settings with dot notation into nested dictionaries. Uses defaultdict for more concise code and automatic creation of nested levels. :param settings_str: String containing comma-separated key=value pairs, where keys can use dot notation for nesting :type settings_str: str :return: Nested dictionary structure based on the dot notation :rtype: dict **Examples:** .. code-block:: python >>> OptionParser.parse_hierarchical_dict( ... "incar.elem=1,relax_settings.algo=rd,options.resources.num_machines=1" ... ) {'incar': {'elem': 1}, 'relax_settings': {'algo': 'rd'}, 'options': {'resources': {'num_machines': 1}}} >>> OptionParser.parse_hierarchical_dict("debug.enabled=true,solver.tolerance=1e-6,mesh.nx=100") {'debug': {'enabled': True}, 'solver': {'tolerance': 1e-06}, 'mesh': {'nx': 100}} """ if not settings_str: return {} result = cls.nested_dict() for pair in settings_str.split(','): if '=' not in pair: continue key_path, value = pair.split('=', 1) key_path = key_path.strip() value = value.strip() # Convert value to appropriate type converted_value = cls.convert_value(value) # Split the key path by dots and strip whitespace keys = [k.strip() for k in key_path.split('.')] # Navigate to the nested location using defaultdict current_dict = result for key in keys[:-1]: current_dict = current_dict[key] # Set the final value current_dict[keys[-1]] = converted_value # Convert defaultdict back to regular dict for clean output return cls._defaultdict_to_dict(result)
[docs] @staticmethod def convert_value(value: str): """Convert string value to appropriate Python type. :param value: String value to convert :type value: str :return: Converted value (int, float, bool, None, or str) :rtype: int or float or bool or None or str **Supported conversions:** * Integers: ``"123"`` → ``123`` * Floats: ``"1.5"`` → ``1.5`` * Booleans: ``"true"``, ``"yes"``, ``"on"``, ``"1"`` → ``True`` * Booleans: ``"false"``, ``"no"``, ``"off"``, ``"0"`` → ``False`` * None: ``"None"``, ``"null"``, ``"nil"`` → ``None`` * Strings: Everything else remains as string """ if not value: return value # Try to convert to None if value.lower() in ('none', 'null', 'nil'): return None # Try to convert to int try: return int(value) except ValueError: pass # Try to convert to float try: return float(value) except ValueError: pass # Try to convert to boolean if value.lower() in ('true', 'yes', 'on', '1'): return True elif value.lower() in ('false', 'no', 'off', '0'): return False # Return as string return value
[docs] @staticmethod def _defaultdict_to_dict(d): """Recursively convert defaultdict to regular dict. :param d: Input data structure to convert :return: Regular dictionary with all defaultdicts converted :rtype: dict or original type """ if isinstance(d, defaultdict): return {k: OptionParser._defaultdict_to_dict(v) for k, v in d.items()} return d
[docs] @classmethod def _parse_text_as_dict(cls, resources_str: str) -> dict: """Parse resources from various formats defined directly as a string. :param resources_str: String to parse (JSON or key=value format) :type resources_str: str :return: Parsed dictionary :rtype: dict """ if not resources_str: return {} # Try JSON first try: return json.loads(resources_str) except json.JSONDecodeError: pass # Try key=value format return cls.parse_hierarchical_dict(resources_str)
[docs] @staticmethod def _load_dict_from_file(overrides_path: Path | str) -> dict: """Load some settings from a file. :param overrides_path: Path to the file containing settings :type overrides_path: Path or str :return: Dictionary loaded from file :rtype: dict :raises: json.JSONDecodeError, yaml.YAMLError for malformed files """ if not overrides_path: return {} overrides_path = Path(overrides_path) extension = overrides_path.suffix.lower() with open(overrides_path, 'r', encoding='utf8') as f: if extension in ['.json']: return json.load(f) elif extension in ['.yaml', '.yml']: return yaml.safe_load(f) else: # Try YAML first, then JSON content = f.read() try: return yaml.safe_load(content) except yaml.YAMLError: return json.loads(content)
# Convenience functions for backward compatibility
[docs] def process_dict_option(value: str | None) -> dict: """Backward compatibility wrapper for OptionParser.process_dict_option. :param value: String value that can be JSON, file path, or hierarchical key=value pairs :type value: str or None :return: Parsed dictionary from the input value :rtype: dict """ return OptionParser.process_dict_option(value)
[docs] def parse_hierarchical_dict(settings_str: str) -> dict: """Backward compatibility wrapper for OptionParser.parse_hierarchical_dict. :param settings_str: String containing comma-separated key=value pairs :type settings_str: str :return: Nested dictionary structure based on the dot notation :rtype: dict """ return OptionParser.parse_hierarchical_dict(settings_str)
[docs] def convert_value(value: str): """Backward compatibility wrapper for OptionParser.convert_value. :param value: String value to convert :type value: str :return: Converted value (int, float, bool, None, or str) :rtype: int or float or bool or None or str """ return OptionParser.convert_value(value)