diff --git a/python/ngen_init_config/src/ngen/init_config/_serlializers.py b/python/ngen_init_config/src/ngen/init_config/_serlializers.py deleted file mode 100644 index 42e2cfbf..00000000 --- a/python/ngen_init_config/src/ngen/init_config/_serlializers.py +++ /dev/null @@ -1,74 +0,0 @@ -import configparser -from io import StringIO -from collections import OrderedDict - -from pydantic import BaseModel - -from .utils import try_import -from ._constants import NO_SECTIONS - - -def to_namelist_str(m: BaseModel) -> str: - f90nml = try_import("f90nml", extras_require_name="namelist") - - # NOTE: Cast to OrderedDict to guarantee group-name ordering - # dicts are ordered in python >= 3.7, however f90nml does an isinstance OrderedDict check - namelist = f90nml.Namelist(OrderedDict(m.dict(by_alias=True))) - # drop eol chars - return str(namelist).rstrip() - - -def to_ini_str( - m: BaseModel, space_around_delimiters: bool = True, preserve_key_case: bool = False -) -> str: - cp = configparser.ConfigParser(interpolation=None) - if preserve_key_case: - cp.optionxform = str - cp.read_dict(m.dict(by_alias=True)) - with StringIO() as s: - cp.write(s, space_around_delimiters=space_around_delimiters) - # drop eol chars configparser adds to end of file - return s.getvalue().rstrip() - - -def to_ini_no_section_header_str( - m: BaseModel, space_around_delimiters: bool = True, preserve_key_case: bool = False -) -> str: - cp = configparser.ConfigParser(interpolation=None) - if preserve_key_case: - cp.optionxform = str - data = {NO_SECTIONS: m.dict(by_alias=True)} - cp.read_dict(data) - with StringIO() as s: - cp.write(s, space_around_delimiters=space_around_delimiters) - buff = s.getvalue() - # drop the [NO_SECTION] header and drop eol chars configparser adds to - # end of file - return buff[buff.find("\n") + 1 : ].rstrip() - - -def to_yaml_str(m: BaseModel) -> str: - yaml = try_import("yaml", extras_require_name="yaml") - - # see: https://github.com/yaml/pyyaml/issues/234 - # solution from https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586 - # hopefully this is resolved in the future, it would be nice to try and use yaml.CDumper instead - # of yaml.Dumper - class Dumper(yaml.Dumper): - def increase_indent(self, flow=False, *args, **kwargs): - # this resolves how lists are indented. without this, they are indented inline with keys - return super().increase_indent(flow=flow, indentless=False) - - data = m.dict(by_alias=True) - # drop eol chars - return yaml.dump( - data, - Dumper=Dumper, - ).rstrip() - - -def to_toml_str(m: BaseModel) -> str: - tomli_w = try_import("tomli_w", extras_require_name="toml") - data = m.dict(by_alias=True) - # drop eol chars - return tomli_w.dumps(data).rstrip() diff --git a/python/ngen_init_config/src/ngen/init_config/_version.py b/python/ngen_init_config/src/ngen/init_config/_version.py index 81f0fdec..b1a19e32 100644 --- a/python/ngen_init_config/src/ngen/init_config/_version.py +++ b/python/ngen_init_config/src/ngen/init_config/_version.py @@ -1 +1 @@ -__version__ = "0.0.4" +__version__ = "0.0.5" diff --git a/python/ngen_init_config/src/ngen/init_config/format_serializers.py b/python/ngen_init_config/src/ngen/init_config/format_serializers.py new file mode 100644 index 00000000..7ec436ee --- /dev/null +++ b/python/ngen_init_config/src/ngen/init_config/format_serializers.py @@ -0,0 +1,122 @@ +import configparser +from collections import OrderedDict +from io import StringIO +from typing import Any, Dict, List, Union + +from typing_extensions import TypeAlias + +from ._constants import NO_SECTIONS +from .utils import try_import + + +def to_namelist_str(d: Dict[str, Any]) -> str: + """Serialize a dictionary as an namelist formatted string.""" + f90nml = try_import("f90nml", extras_require_name="namelist") + + # NOTE: Cast to OrderedDict to guarantee group-name ordering + # dicts are ordered in python >= 3.7, however f90nml does an isinstance OrderedDict check + namelist = f90nml.Namelist(OrderedDict(d)) + # drop eol chars + return str(namelist).rstrip() + + +_NON_NONE_JSON_PRIMITIVES: TypeAlias = Union[str, float, int, bool] +NON_NONE_JSON_DICT: TypeAlias = Dict[ + str, + Union[ + _NON_NONE_JSON_PRIMITIVES, + "NON_NONE_JSON_DICT", + List[_NON_NONE_JSON_PRIMITIVES], + List["NON_NONE_JSON_DICT"], + ], +] +"""Dictionary of non-None json serializable types.""" + + +def to_ini_str( + d: Dict[str, NON_NONE_JSON_DICT], + *, + space_around_delimiters: bool = True, + preserve_key_case: bool = False, +) -> str: + """ + Serialize a dictionary as an ini formatted string. + Note: this function does not support serializing `None` values. + `None` values must either be discarded or transformed into an appropriate type before using this + function. + + Parameters + ---------- + d : Dict[str, NON_NONE_JSON_DICT] + dictionary of sections and their contents to serialize + space_around_delimiters : bool, optional + include spaces around `=` signs, by default True + preserve_key_case : bool, optional + preserve case of keys, by default False + """ + cp = configparser.ConfigParser(interpolation=None) + if preserve_key_case: + cp.optionxform = str + cp.read_dict(d) + with StringIO() as s: + cp.write(s, space_around_delimiters=space_around_delimiters) + # drop eol chars configparser adds to end of file + return s.getvalue().rstrip() + + +def to_ini_no_section_header_str( + d: NON_NONE_JSON_DICT, + space_around_delimiters: bool = True, + preserve_key_case: bool = False, +) -> str: + """ + Serialize a dictionary as an ini-_like_ formatted string. + Does not include a section header in the output string. + Note: this function does not support serializing `None` values. + `None` values must either be discarded or transformed into an appropriate type before using this + function. + + Parameters + ---------- + d : Dict[str, NON_NONE_JSON_DICT] + dictionary to serialize + space_around_delimiters : bool, optional + include spaces around `=` signs, by default True + preserve_key_case : bool, optional + preserve case of keys, by default False + """ + cp = configparser.ConfigParser(interpolation=None) + if preserve_key_case: + cp.optionxform = str + data = {NO_SECTIONS: d} + cp.read_dict(data) + with StringIO() as s: + cp.write(s, space_around_delimiters=space_around_delimiters) + buff = s.getvalue() + # drop the [NO_SECTION] header and drop eol chars configparser adds to + # end of file + return buff[buff.find("\n") + 1 :].rstrip() + + +def to_yaml_str(d: Dict[str, Any]) -> str: + """Serialize a dictionary as a yaml formatted string.""" + yaml = try_import("yaml", extras_require_name="yaml") + + # see: https://github.com/yaml/pyyaml/issues/234 + # solution from https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586 + # hopefully this is resolved in the future, it would be nice to try and use yaml.CDumper instead + # of yaml.Dumper + class Dumper(yaml.Dumper): + def increase_indent(self, flow=False, *args, **kwargs): + # this resolves how lists are indented. without this, they are indented inline with keys + return super().increase_indent(flow=flow, indentless=False) + + # drop eol chars + return yaml.dump(d, Dumper=Dumper).rstrip() + + +def to_toml_str(d: Dict[str, Any]) -> str: + """Serialize a dictionary as a toml formatted string.""" + tomli_w = try_import("tomli_w", extras_require_name="toml") + # drop eol chars + return tomli_w.dumps(d).rstrip() diff --git a/python/ngen_init_config/src/ngen/init_config/serializer.py b/python/ngen_init_config/src/ngen/init_config/serializer.py index 77d8891c..a4b86344 100644 --- a/python/ngen_init_config/src/ngen/init_config/serializer.py +++ b/python/ngen_init_config/src/ngen/init_config/serializer.py @@ -1,18 +1,10 @@ import os -from pathlib import Path +import pathlib -from .core import Base -from .utils import merge_class_attr -from ._serlializers import ( - to_namelist_str, - to_ini_str, - to_ini_no_section_header_str, - to_yaml_str, - to_toml_str, -) +from . import core, format_serializers, utils -class IniSerializer(Base): +class IniSerializer(core.Base): """Blanket implementation for serializing into ini format. Python's standard library `configparser` package is used to handle serialization. @@ -30,65 +22,47 @@ class IniSerializer(Base): If True, keys will be case sensitively serialized (default: `False`) """ - class Config(Base.Config): + class Config(core.Base.Config): no_section_headers: bool = False space_around_delimiters: bool = True preserve_key_case: bool = False - def to_ini(self, p: Path) -> None: + def to_ini(self, p: pathlib.Path) -> None: with open(p, "w") as f: - if self._no_section_headers: - b_written = f.write( - to_ini_no_section_header_str( - self, - space_around_delimiters=self._space_around_delimiters, - preserve_key_case=self._preserve_key_case, - ) - ) - # add eol - if b_written: - f.write(os.linesep) - return - - b_written = f.write( - to_ini_str( - self, - space_around_delimiters=self._space_around_delimiters, - preserve_key_case=self._preserve_key_case, - ) - ) - # add eol + b_written = f.write(self.to_ini_str()) if b_written: + # add eol f.write(os.linesep) def to_ini_str(self) -> str: + data = self.dict(by_alias=True) if self._no_section_headers: - return to_ini_no_section_header_str( - self, + return format_serializers.to_ini_no_section_header_str( + data, space_around_delimiters=self._space_around_delimiters, preserve_key_case=self._preserve_key_case, ) - return to_ini_str( - self, + return format_serializers.to_ini_str( + data, space_around_delimiters=self._space_around_delimiters, preserve_key_case=self._preserve_key_case, ) @property def _space_around_delimiters(self) -> bool: - return merge_class_attr(type(self), "Config.space_around_delimiters", True) # type: ignore + return utils.merge_class_attr(type(self), "Config.space_around_delimiters", True) # type: ignore @property def _no_section_headers(self) -> bool: - return merge_class_attr(type(self), "Config.no_section_headers", False) # type: ignore + return utils.merge_class_attr(type(self), "Config.no_section_headers", False) # type: ignore @property def _preserve_key_case(self) -> bool: - return merge_class_attr(type(self), "Config.preserve_key_case", False) # type: ignore + return utils.merge_class_attr(type(self), "Config.preserve_key_case", False) # type: ignore -class NamelistSerializer(Base): +class NamelistSerializer(core.Base): """Blanket implementation for serializing into FORTRAN namelist format. The `f90nml` package is used to handle serialization. `f90nml` is not included in default installations of `ngen.init_config`. Install `ngen.init_config` with `f90nml` using the extra install option, @@ -97,19 +71,18 @@ class NamelistSerializer(Base): Fields are serialized using their alias, if provided. """ - def to_namelist(self, p: Path) -> None: + def to_namelist(self, p: pathlib.Path) -> None: with open(p, "w") as f: - b_written = f.write(to_namelist_str(self)) + b_written = f.write(self.to_namelist_str()) if b_written: # add eol f.write(os.linesep) - def to_namelist_str(self) -> str: - return to_namelist_str(self) + return format_serializers.to_namelist_str(self.dict(by_alias=True)) -class YamlSerializer(Base): +class YamlSerializer(core.Base): """Blanket implementation for serializing from yaml format. The `PyYAML` package is used to handle serialization. `PyYAML` is not included in default installations of `ngen.init_config`. Install `ngen.init_config` with `PyYAML` using the extra install option, `yaml`. @@ -117,18 +90,18 @@ class YamlSerializer(Base): Fields are serialized using their alias, if provided. """ - def to_yaml(self, p: Path) -> None: + def to_yaml(self, p: pathlib.Path) -> None: with open(p, "w") as f: - b_written = f.write(to_yaml_str(self)) + b_written = f.write(self.to_yaml_str()) if b_written: # add eol f.write(os.linesep) def to_yaml_str(self) -> str: - return to_yaml_str(self) + return format_serializers.to_yaml_str(self.dict(by_alias=True)) -class TomlSerializer(Base): +class TomlSerializer(core.Base): """Blanket implementation for serializing from `toml` format. The `tomli_w` package is used to handle serialization. `tomli_w` is not included in default installations of `ngen.init_config`. Install `ngen.init_config` with `tomli_w` using the extra install option, `toml`. @@ -136,25 +109,25 @@ class TomlSerializer(Base): Fields are serialized using their alias, if provided. """ - def to_toml(self, p: Path) -> None: + def to_toml(self, p: pathlib.Path) -> None: with open(p, "w") as f: - b_written = f.write(to_toml_str(self)) + b_written = f.write(self.to_toml_str()) # add eol if b_written: f.write(os.linesep) def to_toml_str(self) -> str: - return to_toml_str(self) + return format_serializers.to_toml_str(self.dict(by_alias=True)) -class JsonSerializer(Base): +class JsonSerializer(core.Base): """Blanket implementation for serializing to `json` format. This functionality is provided by `pydantic`. See `pydantic`'s documentation for other configuration options. Fields are serialized using their alias, if provided. """ - def to_json(self, p: Path, *, indent: int = 0) -> None: + def to_json(self, p: pathlib.Path, *, indent: int = 0) -> None: with open(p, "w") as f: b_written = f.write(self.to_json_str(indent=indent)) # add eol