"""diot module""" from contextlib import contextmanager from copy import deepcopy from os import PathLike from typing import ( TYPE_CHECKING, Any, Callable, Dict, Iterable, Iterator, Mapping, Optional, Tuple, Union, ) from .transforms import TRANSFORMS from .utils import DiotFrozenError, nest, to_dict if TYPE_CHECKING: from argparse import Namespace class Diot(dict): """Dictionary with dot notation Examples: >>> d = Diot(a=1, b=2) >>> d.a = 2 >>> d['a'] = 2 >>> d.a # 2 >>> d['a'] # 2 >>> d.pop('a') # 2 >>> d.pop('x', 1) # 1 >>> d.popitem() # ('b', 2) >>> d.update(a=3, b=4) # {'a': 3, 'b': 4} >>> d | {'a': 1, 'b': 2} # {'a': 1, 'b': 2} (d unchanged) >>> d |= {'a': 1, 'b': 2} # d == {'a': 1, 'b': 2} >>> del d.a >>> del d['b'] >>> d.freeze() >>> d.a = 1 # DiotFrozenError >>> d.unfreeze() >>> d.a = 1 # ok >>> d.setdefault('b', 2) >>> 'b' in d >>> d.copy() >>> d.deepcopy() Args: *args: Anything that can be sent to dict construct **kwargs: keyword argument that can be sent to dict construct Some diot configurations can also be passed, including: diot_nest: Types to nestly convert values diot_transform: The transforms for keys diot_frozen: Whether to generate a frozen diot. True: freeze the object recursively if there are Diot objects in descendants False: Don'f freeze 'shallow': Only freeze at depth = 1 """ __slots__ = ("__diot__", "__dict__") def __new__(cls, *args, **kwargs): ret = super().__new__(cls) # unpickling will not call __init__ # we use a flag '__inited__' to tell if __init__ has been called # is there a better way? ret.__init__(*args, **kwargs) return ret @classmethod def from_namespace( cls, namespace: "Namespace", recursive: bool = True, diot_nest: Union[bool, Iterable[type]] = True, diot_transform: Union[Callable[[str], str], str] = "safe", diot_frozen: Union[bool, str] = False, ) -> "Diot": """Get a Diot object from an argparse namespace Example: >>> from argparse import Namespace >>> Diot.from_namespace(Namespace(a=1, b=2)) Args: namespace: The namespace object recursive: Do it recursively? diot_nest: Types to nestly convert values diot_transform: The transforms for keys diot_frozen: Whether to generate a frozen diot. - True: freeze the object recursively if there are Diot objects in descendants - False: Don'f freeze - `shallow`: Only freeze at depth = 1 diot_missing: How to deal with missing keys when accessing them - An exception class or object to raise - `None` to return `None` - A custom function with first argument the key and second the diot object. Returns: The converted diot object. """ from argparse import Namespace ret = cls( { key: val for key, val in vars(namespace).items() if not key.startswith("__") }, diot_nest=diot_nest, diot_transform=diot_transform, diot_frozen=diot_frozen, ) if not recursive: return ret for key, value in ret.items(): if isinstance(value, Namespace): ret[key] = cls.from_namespace(value) return ret def __init__(self, *args, **kwargs): if self.__dict__.get("__inited__"): return self.__dict__["__inited__"] = True self.__dict__.setdefault("__diot__", {}) self.__diot__["keymaps"] = {} self.__diot__["nest"] = kwargs.pop("diot_nest", True) self.__diot__["nest"] = ( [dict, list, tuple] if self.__diot__["nest"] is True else [] if self.__diot__["nest"] is False else list(self.__diot__["nest"]) if isinstance(self.__diot__["nest"], tuple) else self.__diot__["nest"] if isinstance(self.__diot__["nest"], list) else [self.__diot__["nest"]] ) self.__diot__["transform"] = kwargs.pop("diot_transform", "safe") self.__diot__["frozen"] = False self.__diot__["missing"] = kwargs.pop("diot_missing", KeyError) diot_frozen = kwargs.pop("diot_frozen", False) if isinstance(self.__diot__["transform"], str): self.__diot__["transform"] = TRANSFORMS[self.__diot__["transform"]] super().__init__(*args, **kwargs) for key in self: transformed_key = self.__diot__["transform"](key) if transformed_key in self.__diot__["keymaps"]: raise KeyError( f"Keys {self.__diot__['keymaps'][transformed_key]!r} and " f"{key!r} will be transformed to the same attribute. " "Either change one of them or use a different " "diot_transform function." ) self.__diot__["keymaps"][transformed_key] = key # nest values for key in self: self[key] = nest( self[key], self.__diot__["nest"], self.__class__, self.__diot__["frozen"] is True, ) self.__diot__["frozen"] = diot_frozen def __setattr__(self, name: str, value: Any) -> None: if self.__diot__["frozen"]: raise DiotFrozenError("Cannot set attribute to a frozen diot.") self[name] = nest( value, self.__diot__["nest"], self.__class__, self.__diot__["frozen"], ) def __setitem__(self, name: str, value: Any) -> None: if self.__diot__["frozen"]: raise DiotFrozenError("Cannot set item to a frozen diot.") transformed_key = self.__diot__["transform"](name) if ( transformed_key in self.__diot__["keymaps"] and transformed_key != name and self.__diot__["keymaps"][transformed_key] != name and value is not self[transformed_key] ): raise KeyError( f"{name!r} will be transformed to the same attribute as " f"{self.__diot__['keymaps'][transformed_key]!r}. " "Either use a different name or " "a different diot_transform function." ) self.__diot__["keymaps"][transformed_key] = name super().__setitem__( name, nest( value, self.__diot__["nest"], self.__class__, self.__diot__["frozen"] is True, ), ) def __getattr__(self, name: str) -> Any: if name == "__diot__": return self.__dict__["__diot__"] try: return self[name] except Exception as exc: raise AttributeError(name) from exc def __getitem__(self, name: str) -> Any: original_key = self.__diot__["keymaps"].get(name, name) try: return super().__getitem__(original_key) except KeyError as keyerr: missing_handler = self.__diot__["missing"] if missing_handler is None: return None if isinstance(missing_handler, Exception): raise missing_handler from None if isinstance(missing_handler, type) and issubclass( missing_handler, Exception ): raise missing_handler(str(keyerr)) from None return missing_handler(name, self) # type: ignore def pop(self, name: str, *value) -> Any: """Pop a key from the object and return the value. If key does not exist, return the given default value Args: name: The key value: The default value to return if the key does not exist Returns: The value corresponding to the name or the default value Raises: DiotFrozenError: when try to pop from a frozen diot """ if self.__diot__["frozen"]: raise DiotFrozenError("Cannot pop a frozen diot.") if name in self.__diot__["keymaps"]: name = self.__diot__["keymaps"][name] del self.__diot__["keymaps"][name] if value: return super().pop(name, value[0]) return super().pop(name) def popitem(self) -> Tuple[str, Any]: """Pop last item from the object Returns: A tuple of key and value Raises: DiotFrozenError: when try to pop from a frozen diot """ if self.__diot__["frozen"]: raise DiotFrozenError("Cannot popitem of a frozen diot.") key, val = super().popitem() if key in self.__diot__["keymaps"]: del self.__diot__["keymaps"][key] else: del self.__diot__["keymaps"][self.__diot__["transform"](key)] return key, val def update(self, *value, **kwargs) -> None: """Update the object. Shortcut: `|=` Args: args: args that can be sent to dict to update the object kwargs: kwargs that can be sent to dict to update the object Raises: DiotFrozenError: when try to update a frozen diot """ if self.__diot__["frozen"]: raise DiotFrozenError("Cannot update a frozen diot.") dict_to_update = dict(*value, **kwargs) for key, val in dict_to_update.items(): if ( key not in self or not isinstance(self[key], dict) or not isinstance(val, dict) ): self[key] = nest( val, self.__diot__["nest"], self.__class__, self.__diot__["frozen"] is True, ) else: self[key].update(val) def __or__(self, other: Mapping) -> "Diot": ret = self.copy() ret.update(other) return ret def __ior__(self, other: Mapping) -> "Diot": self.update(other) return self def __delitem__(self, name: str) -> None: if self.__diot__["frozen"]: raise DiotFrozenError("Cannot delete from a frozen diot.") if name in self.__diot__["keymaps"]: super().__delitem__(self.__diot__["keymaps"][name]) del self.__diot__["keymaps"][name] else: super().__delitem__(name) del self.__diot__["keymaps"][self.__diot__["transform"](name)] __delattr__ = __delitem__ def _repr(self, hide=None, items="dict"): """Compose the repr for the object. If the config item is default, hide it. If argument hide is specified, hide that item anyway""" diot_class = self.__class__.__name__ diot_transform = self.__diot__["transform"] for key, val in TRANSFORMS.items(): if val is diot_transform: diot_transform = key break diot_transform = ( None if diot_transform == "safe" or hide == "transform" else diot_transform ) diot_transform = ( "" if diot_transform is None else f", diot_transform={diot_transform}" ) diot_nest = ",".join( sorted(dn.__name__ for dn in self.__diot__["nest"]) ) diot_nest = ( None if diot_nest == "dict,list,tuple" or hide == "next" else diot_nest ) diot_nest = "" if diot_nest is None else f", diot_nest={diot_nest}" diot_frozen = ( None if self.__diot__["frozen"] is False or hide == "frozen" else self.__diot__["frozen"] ) diot_frozen = ( "" if diot_frozen is None else f", diot_frozen={diot_frozen}" ) diot_items = self if items == "dict" else list(self.items()) return ( f"{diot_class}({diot_items}" f"{diot_transform}{diot_nest}{diot_frozen})" ) def __repr__(self) -> str: return self._repr() def __str__(self) -> str: return repr(dict(self)) def freeze(self, frozen: Union[str, bool] = "shallow") -> None: """Freeze the diot object Args: frozen: The frozen argument indicating how to freeze: shallow: only freeze at depth=1 True: freeze recursively if there are diot objects in children False: Disable freezing """ self.__diot__["frozen"] = frozen if frozen is True: for val in self.values(): if isinstance(val, Diot): val.freeze(True) def unfreeze(self, recursive: bool = False) -> None: """Unfreeze the diot object Args: recursive: Whether unfreeze all diot objects recursively """ self.__diot__["frozen"] = False if recursive: for val in self.values(): if isinstance(val, Diot): val.unfreeze(True) @contextmanager def thaw(self, recursive: bool = False): """A context manager for temporarily change the diot Args: recursive: Whether unfreeze all diot objects recursively Yields: self, the reference to this diot. """ self.unfreeze(recursive) yield self self.freeze(recursive or "shallow") def setdefault( # type: ignore[override] self, name: str, value: Any, ) -> Any: """Set a default value to a key Args: name: The key name value: The default value Returns: The existing value or the value passed in Raises: DiotFrozenError: when try to set default to a frozen diot """ if self.__diot__["frozen"]: raise DiotFrozenError("Cannot setdefault to a frozen diot.") if name in self: return self[name] self[name] = value return self[name] def accessible_keys(self) -> Iterable[str]: """Get the converted keys Returns: The accessible (transformed) keys """ return self.__diot__["keymaps"].keys() def get(self, name: str, value: Any = None) -> Any: """Get the value of a key name Args: name: The key name value: The value to return if the key does not exist Returns: The corresponding value or the value passed in if the key does not exist """ name = self.__diot__["keymaps"].get(name, name) return super().get( name, nest( value, self.__diot__["nest"], self.__class__, self.__diot__["frozen"] is True, ), ) def __contains__(self, name: Any) -> bool: if name in self.__diot__["keymaps"]: return True return super().__contains__(name) def clear(self) -> None: """Clear the object""" if self.__diot__["frozen"]: raise DiotFrozenError("Cannot clear a frozen diot.") super().clear() self.__diot__["keymaps"].clear() def copy(self) -> "Diot": """Shallow copy the object Returns: The copied object """ return self.__class__( list(self.items()), diot_nest=self.__diot__["nest"], diot_transform=self.__diot__["transform"], diot_frozen=self.__diot__["frozen"], ) __copy__ = copy def __deepcopy__(self, memo: Optional[Dict[int, Any]] = None) -> "Diot": out = self.__class__( diot_nest=self.__diot__["nest"], diot_transform=self.__diot__["transform"], diot_frozen=self.__diot__["frozen"], ) memo = memo or {} memo[id(self)] = out for key, value in self.items(): out[key] = deepcopy(value, memo) return out # for pickling and unpickling def __getstate__(self): return {} def __getnewargs_ex__(self): return ( (list(self.items()),), { "diot_transform": self.__diot__["transform"], "diot_nest": self.__diot__["nest"], "diot_frozen": self.__diot__["frozen"], }, ) def to_dict(self) -> Dict[str, Any]: """ Turn the Box and sub Boxes back into a native python dictionary. Returns: The converted python dictionary """ return to_dict(self) dict = as_dict = to_dict def to_json( self, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs, ) -> Optional[str]: """Convert to a json string or save it to json file Args: filename: The filename to save the json to, if not given a json string will be returned encoding: The encoding for saving to file errors: The errors handling for saveing to file See python's open function **json_kwargs: Other kwargs for json.dumps Returns: The json string with filename is not given """ import json json_dump = json.dumps( self.to_dict(), ensure_ascii=False, **json_kwargs ) if not filename: return json_dump with open(filename, "w", encoding=encoding, errors=errors) as fjs: fjs.write(json_dump) return None json = as_json = to_json def to_yaml( self, filename: Optional[Union[str, PathLike]] = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", **yaml_kwargs, ) -> Optional[str]: """Convert to a yaml string or save it to yaml file Args: filename: The filename to save the yaml to, if not given a yaml string will be returned default_flow_style: The default flow style for yaml dumping See `yaml.dump` encoding: The encoding for saving to file errors: The errors handling for saveing to file See python's open function **yaml_kwargs: Other kwargs for `yaml.dump` Returns: The yaml string with filename is not given """ try: import yaml # type: ignore[import] except ImportError as exc: # pragma: no cover raise ImportError( "You need pyyaml installed to export Diot as yaml." ) from exc yaml_dump = self.to_dict() if not filename: return yaml.dump( yaml_dump, default_flow_style=default_flow_style, **yaml_kwargs ) with open(filename, "w", encoding=encoding, errors=errors) as fyml: yaml.dump( yaml_dump, stream=fyml, default_flow_style=default_flow_style, **yaml_kwargs, ) return None yaml = as_yaml = to_yaml def to_toml( self, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", ) -> Optional[str]: """Convert to a toml string or save it to toml file Args: filename: The filename to save the toml to, if not given a toml string will be returned encoding: The encoding for saving to file errors: The errors handling for saveing to file See python's open function Returns: The toml string with filename is not given """ try: import rtoml # type: ignore[import] except ImportError as exc: # pragma: no cover raise ImportError( "You need toml installed to export Diot as toml." ) from exc toml_dump = self.to_dict() if not filename: return rtoml.dumps(toml_dump) with open(filename, "w", encoding=encoding, errors=errors) as ftml: rtoml.dump(toml_dump, ftml) return None toml = as_toml = to_toml class CamelDiot(Diot): """With camel case conversion""" def __init__(self, *args, **kwargs): kwargs["diot_transform"] = TRANSFORMS["camel_case"] super().__init__(*args, **kwargs) def __repr__(self) -> str: return self._repr(hide="transform") class SnakeDiot(Diot): """With snake case conversion""" def __init__(self, *args, **kwargs): kwargs["diot_transform"] = TRANSFORMS["snake_case"] super().__init__(*args, **kwargs) def __repr__(self) -> str: return self._repr(hide="transform") class FrozenDiot(Diot): """The frozen diot""" def __init__(self, *args, **kwargs): kwargs["diot_frozen"] = True super().__init__(*args, **kwargs) def __repr__(self) -> str: return self._repr(hide="frozen") class OrderedDiot(Diot): """With key order preserved""" def __init__(self, *args, **kwargs): self.__dict__.setdefault("__diot__", {}) self.__diot__["orderedkeys"] = [ key[0] if isinstance(key, tuple) else key for arg in args for key in arg ] + [key for key in kwargs if not key.startswith("diot_")] super().__init__(*args, **kwargs) def __repr__(self): return self._repr(items="items") def __setitem__(self, name: str, value: Any) -> None: super().__setitem__(name, value) if name not in self.__diot__["orderedkeys"]: self.__diot__["orderedkeys"].append(name) def items(self) -> Iterator[Tuple[str, Any]]: # type: ignore[override] """Get the items in the order of the keys Returns: The items (key-value) of the object """ return ((key, self[key]) for key in self.__diot__["orderedkeys"]) def insert( self, position: int, name: Union[str, Tuple[str, Any]], value: Any = None, ) -> None: """Insert an item to certain position Args: position: The position where the name-value pair to be inserted name: The key name to be inserted It could also be a tuple of key-value pair. In such a case, value is ignored. It could be an ordered dictionary as well value: The value to be inserted Raises: ValueError: when try to pass a value if name is key-value pair or a dictonary. ValueError: when name is a tuple but not with 2 elements """ if position is None: position = len(self) if isinstance(name, tuple): # key-value pair if value is not None: raise ValueError( "Unnecessary value provided when key-value pair is passed." ) if len(name) != 2: raise ValueError( "Expecting a key-value pair (tuple with 2 elements)." ) name, value = name self.__diot__["orderedkeys"].insert(position, name) self[name] = value elif isinstance(name, dict): if value is not None: raise ValueError( "Unnecessary value provided when " "a ordered-dictionary passed." ) self.__diot__["orderedkeys"][position:position] = list(name.keys()) for key, val in name.items(): self[key] = val else: self.__diot__["orderedkeys"].insert(position, name) self[name] = value def insert_before( self, existing_key: str, name: str, value: Any = None ) -> None: """Insert items before the specified key Args: existing_key: The key where the new elements to be inserted before name: The key name to be inserted value: The value to be inserted Same as name and value arguments for `insert` Raises: KeyError: when existing key does not exist KeyError: when name is an existing key """ try: position = self.__diot__["orderedkeys"].index(existing_key) except ValueError as vex: raise KeyError("No such key: %s" % existing_key) from vex if name in self.__diot__["orderedkeys"]: raise KeyError("Key already exists: %s" % name) self.insert(position, name, value) def insert_after( self, existing_key: str, name: str, value: Any = None ) -> None: """Insert items after the specified key Args: existing_key: The key where the new elements to be inserted after name: The key name to be inserted value: The value to be inserted Same as name and value arguments for `insert` Raises: KeyError: when existing key does not exist KeyError: when name is an existing key """ try: position = self.__diot__["orderedkeys"].index(existing_key) except ValueError as vex: raise KeyError("No such key: %s" % existing_key) from vex if name in self.__diot__["orderedkeys"]: raise KeyError("Key already exists: %s" % name) self.insert(position + 1, name, value) def keys(self) -> Iterable: # type: ignore[override] """Get the keys in the order they are added Returns: The keys (untransformed) """ return (key for key in self.__diot__["orderedkeys"]) def __iter__(self) -> Iterable: # type: ignore[override] return iter(self.keys()) def values(self) -> Iterable: # type: ignore[override] """Get the values in the order they are added Returns: The values of the object """ return (self[key] for key in self.__diot__["orderedkeys"]) def __delitem__(self, name: str) -> None: super().__delitem__(name) name = self.__diot__["keymaps"].get(name, name) del self.__diot__["orderedkeys"][ self.__diot__["orderedkeys"].index(name) ] __delattr__ = __delitem__ def pop(self, name: str, *value) -> Any: ret = super().pop(name, *value) name = self.__diot__["keymaps"].get(name, name) del self.__diot__["orderedkeys"][ self.__diot__["orderedkeys"].index(name) ] return ret def __reversed__(self) -> Iterable: # type: ignore[override] return reversed(self.__diot__["orderedkeys"]) def clear(self) -> None: super().clear() del self.__diot__["orderedkeys"][:] def copy(self) -> "OrderedDiot": out = self.__class__(super().copy()) out.__diot__["orderedkeys"] = self.__diot__["orderedkeys"][:] return out