From 9c471f8b63ab124e3b7c4b594e85f645c07b007f Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Sun, 24 Apr 2022 23:16:36 +0100 Subject: [PATCH 01/39] Add base module for new dataclass-based impl --- loam/base.py | 125 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_base.py | 47 +++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 loam/base.py create mode 100644 tests/test_base.py diff --git a/loam/base.py b/loam/base.py new file mode 100644 index 0000000..f53cd8b --- /dev/null +++ b/loam/base.py @@ -0,0 +1,125 @@ +"""Main classes to define your configuration.""" + +from __future__ import annotations + +from dataclasses import dataclass, fields, field, Field +from typing import ( + get_type_hints, + TypeVar, Generic, Callable, Optional, Dict, Any, Type +) + +T = TypeVar("T") + + +@dataclass(frozen=True) +class Entry(Generic[T]): + """Metadata of configuration options. + + Attributes: + doc: short description of the option. + in_file: whether the option can be set in the config file. + in_cli: whether the option is a command line argument. + cli_short: short version of the command line argument. + cli_kwargs: keyword arguments fed to + :meth:`argparse.ArgumentParser.add_argument` during the + construction of the command line arguments parser. + cli_zsh_comprule: completion rule for ZSH shell. + """ + + doc: str = "" + from_str: Optional[Callable[[str], T]] = None + to_str: Optional[Callable[[T], str]] = None + in_file: bool = True + in_cli: bool = True + cli_short: Optional[str] = None + cli_kwargs: Dict[str, Any] = field(default_factory=dict) + cli_zsh_comprule: Optional[str] = '' + + def with_str(self, val_as_str: str) -> T: + """Set default value from a string representation. + + This uses :attr:`from_str`. Note that the call itself is embedded in a + factory function to avoid issues if the generated value is mutable. + """ + if self.from_str is None: + raise ValueError("Need `from_str` to call with_str") + func = self.from_str # for mypy to see func is not None here + return self.with_factory(lambda: func(val_as_str)) + + def with_val(self, val: T) -> T: + """Set default value. + + Use :meth:`with_factory` or :meth:`with_str` if the value is mutable. + """ + return field(default=val, metadata=dict(loam_entry=self)) + + def with_factory(self, func: Callable[[], T]) -> T: + """Set default value from a factory function. + + This is useful with the value is mutable. + """ + return field(default_factory=func, metadata=dict(loam_entry=self)) + + +@dataclass(frozen=True) +class _Meta(Generic[T]): + """Group several metadata.""" + + fld: Field[T] + entry: Entry[T] + type_hint: Type[T] + + +@dataclass +class Section: + """Base class for a configuration section. + + This implements :meth:`__post_init__`. If your subclass also implement + it, please call the parent implementation. + """ + + _loam_meta: Dict[str, _Meta] = field( + default_factory=dict, init=False, repr=False, compare=False) + + @classmethod + def _type_hints(cls) -> Dict[str, Any]: + return get_type_hints(cls) + + def __post_init__(self) -> None: + thints = self._type_hints() + for fld in fields(self): + if fld.name == "_loam_meta": + continue + meta = fld.metadata.get("loam_entry", Entry()) + thint = thints[fld.name] + if not isinstance(thint, type): + thint = object + self._loam_meta[fld.name] = _Meta(fld, meta, thint) + + current_val = getattr(self, fld.name) + if (not issubclass(thint, str)) and isinstance(current_val, str): + self.set_from_str(fld.name, current_val) + current_val = getattr(self, fld.name) + if not isinstance(current_val, thint): + typ = type(current_val) + raise TypeError( + f"Expected a {thint} for {fld.name}, received a {typ}.") + + def set_from_str(self, field_name: str, value_as_str: str) -> None: + """Set an option from the string representation of the value. + + This uses :meth:`Entry.from_str` to parse the given string, and + fall back on the type annotation if it resolves to a class. + """ + meta = self._loam_meta[field_name] + if issubclass(meta.type_hint, str): + value = value_as_str + elif meta.entry.from_str is not None: + value = meta.entry.from_str(value_as_str) + else: + try: + value = meta.type_hint(value_as_str) + except TypeError: + raise ValueError( + f"Please specify a `from_str` for {field_name}.") + setattr(self, field_name, value) diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..2fbf9c4 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,47 @@ +from __future__ import annotations +from dataclasses import dataclass +from pathlib import Path + +from loam.base import Entry, Section + + +class MyMut: + def __init__(self, inner_list): + self.inner_list = inner_list + + @staticmethod + def from_str(s: str) -> MyMut: + return MyMut(list(map(float, s.split(",")))) + + +def test_with_val(): + @dataclass + class MySection(Section): + some_n: int = Entry().with_val(42) + + sec = MySection() + assert sec.some_n == 42 + + +def test_set_from_str_type_hint(): + @dataclass + class MySection(Section): + some_n: int = 5 + some_str: str = "foo" + sec = MySection() + assert sec.some_n == 5 + assert sec.some_str == "foo" + sec.set_from_str("some_n", "42") + assert sec.some_n == 42 + sec.set_from_str("some_str", "bar") + assert sec.some_str == "bar" + + +def test_with_str_mutable_protected(): + @dataclass + class MySection(Section): + outdir: Path = Entry(from_str=Path, to_str=str).with_str(".") + some_mut: MyMut = Entry(from_str=MyMut.from_str).with_str("4.5,3.8") + + MySection().some_mut.inner_list.append(5.6) + assert MySection().some_mut.inner_list == [4.5, 3.8] From b38017e27da0da92d58d4ddef7f7b0ea6e33e542 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Sun, 24 Apr 2022 23:30:23 +0100 Subject: [PATCH 02/39] Add tests of failure-cases for base module --- tests/test_base.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_base.py b/tests/test_base.py index 2fbf9c4..5c6f93b 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -2,11 +2,15 @@ from dataclasses import dataclass from pathlib import Path +import pytest + from loam.base import Entry, Section class MyMut: def __init__(self, inner_list): + if not isinstance(inner_list, list): + raise TypeError self.inner_list = inner_list @staticmethod @@ -45,3 +49,26 @@ class MySection(Section): MySection().some_mut.inner_list.append(5.6) assert MySection().some_mut.inner_list == [4.5, 3.8] + + +def test_with_str_no_from_str(): + with pytest.raises(ValueError): + Entry().with_str("5") + + +def test_init_wrong_type(): + @dataclass + class MySection(Section): + some_n: int = 42 + with pytest.raises(TypeError): + MySection(42.0) + + +def test_missing_from_str(): + @dataclass + class MySection(Section): + my_mut: MyMut = Entry().with_factory(lambda: MyMut([4.5])) + sec = MySection() + assert sec.my_mut.inner_list == [4.5] + with pytest.raises(ValueError): + sec.set_from_str("my_mut", "4.5,3.8") From 4939d55493f3bba8715eecb51e3575a9bef7d91f Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Sun, 24 Apr 2022 23:36:17 +0100 Subject: [PATCH 03/39] Add test for case of type hint not being a class --- tests/test_base.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_base.py b/tests/test_base.py index 5c6f93b..45447ca 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -3,6 +3,7 @@ from pathlib import Path import pytest +from typing import Optional from loam.base import Entry, Section @@ -51,6 +52,14 @@ class MySection(Section): assert MySection().some_mut.inner_list == [4.5, 3.8] +def test_type_hint_not_a_class(): + @dataclass + class MySection(Section): + maybe_n: Optional[int] = Entry(from_str=int).with_val(None) + assert MySection().maybe_n is None + assert MySection("42").maybe_n == 42 + + def test_with_str_no_from_str(): with pytest.raises(ValueError): Entry().with_str("5") From 0ea38f315e2abd3848aaded7aebd1ced4c664cb0 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 00:23:00 +0100 Subject: [PATCH 04/39] New Config base class for full config --- loam/base.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/loam/base.py b/loam/base.py index f53cd8b..0ba90c7 100644 --- a/loam/base.py +++ b/loam/base.py @@ -2,12 +2,16 @@ from __future__ import annotations -from dataclasses import dataclass, fields, field, Field +from dataclasses import dataclass, asdict, fields, field, Field +from os import PathLike +from pathlib import Path from typing import ( get_type_hints, - TypeVar, Generic, Callable, Optional, Dict, Any, Type + TypeVar, Generic, Callable, Optional, Dict, Any, Type, Union, Mapping ) +import toml + T = TypeVar("T") @@ -123,3 +127,45 @@ def set_from_str(self, field_name: str, value_as_str: str) -> None: raise ValueError( f"Please specify a `from_str` for {field_name}.") setattr(self, field_name, value) + + +TConfig = TypeVar("TConfig", bound="Config") + + +@dataclass +class Config: + """Base class for a full configuration.""" + + @classmethod + def from_file(cls: Type[TConfig], path: Union[str, PathLike]) -> TConfig: + """Read configuration from toml file.""" + pars = toml.load(Path(path)) + return cls.from_dict(pars) + + @classmethod + def _type_hints(cls) -> Dict[str, Any]: + return get_type_hints(cls) + + @classmethod + def from_dict( + cls: Type[TConfig], options: Mapping[str, Mapping[str, Any]] + ) -> TConfig: + """Create configuration from a dictionary.""" + thints = cls._type_hints() + # check all fields are Section, and type hints are resolved to classes + sections = {} + for fld in fields(cls): + section_dict = options.get(fld.name, {}) + sections[fld.name] = thints[fld.name](**section_dict) + return cls(**sections) + + def to_file(self, path: Union[str, PathLike]) -> None: + """Write configuration in toml file.""" + dct = asdict(self) + for sec_name, sec_dict in dct.items(): + for fld in fields(getattr(self, sec_name)): + entry: Entry = fld.metadata.get("loam_entry", Entry()) + if entry.to_str is not None: + sec_dict[fld.name] = entry.to_str(sec_dict[fld.name]) + with Path(path).open('w') as pf: + toml.dump(dct, pf) From 0245b1c1064f3b4725f376afe488f3a94c7632db Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 02:16:57 +0100 Subject: [PATCH 05/39] Add Config.default method --- loam/base.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/loam/base.py b/loam/base.py index 0ba90c7..ea157cc 100644 --- a/loam/base.py +++ b/loam/base.py @@ -136,6 +136,11 @@ def set_from_str(self, field_name: str, value_as_str: str) -> None: class Config: """Base class for a full configuration.""" + @classmethod + def default(cls: Type[TConfig]) -> TConfig: + """Create a configuration with default values.""" + return cls.from_dict({}) + @classmethod def from_file(cls: Type[TConfig], path: Union[str, PathLike]) -> TConfig: """Read configuration from toml file.""" From 76fd222f5e3e285800043ace8155f49710271c61 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 02:17:26 +0100 Subject: [PATCH 06/39] Add tests of Config.default --- tests/conftest.py | 26 ++++++++++++++++++++++++++ tests/test_base.py | 7 +++++++ 2 files changed, 33 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index f9c52f7..67a6e02 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,11 @@ +from dataclasses import dataclass + import pytest from loam.manager import ConfOpt, Section, ConfigurationManager from loam.cli import Subcmd, CLIManager from loam.tools import switch_opt +import loam.base @pytest.fixture(scope='session', params=['confA']) @@ -61,3 +64,26 @@ def illtoml(tmp_path): path = tmp_path / 'ill.toml' path.write_text('not}valid[toml\n') return path + + +@dataclass +class SectionA(loam.base.Section): + some_n: int = 42 + some_str: str = "foo" + + +@dataclass +class SectionB(loam.base.Section): + some_n: int = 42 + some_str: str = "bar" + + +@dataclass +class MyConfig(loam.base.Config): + section_a: SectionA + section_b: SectionB + + +@pytest.fixture +def my_config() -> MyConfig: + return MyConfig.default() diff --git a/tests/test_base.py b/tests/test_base.py index 45447ca..d9ac396 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -81,3 +81,10 @@ class MySection(Section): assert sec.my_mut.inner_list == [4.5] with pytest.raises(ValueError): sec.set_from_str("my_mut", "4.5,3.8") + + +def test_config_default(my_config): + assert my_config.section_a.some_n == 42 + assert my_config.section_b.some_n == 42 + assert my_config.section_a.some_str == "foo" + assert my_config.section_b.some_str == "bar" From 040fd3fb39a63c7bd0f5e912d3dd3be796d07a38 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 02:22:17 +0100 Subject: [PATCH 07/39] Reuse SectionA via a fixture --- tests/conftest.py | 5 +++++ tests/test_base.py | 19 +++++++------------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 67a6e02..398da65 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,6 +72,11 @@ class SectionA(loam.base.Section): some_str: str = "foo" +@pytest.fixture +def section_a() -> SectionA: + return SectionA() + + @dataclass class SectionB(loam.base.Section): some_n: int = 42 diff --git a/tests/test_base.py b/tests/test_base.py index d9ac396..9736fa5 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -28,18 +28,13 @@ class MySection(Section): assert sec.some_n == 42 -def test_set_from_str_type_hint(): - @dataclass - class MySection(Section): - some_n: int = 5 - some_str: str = "foo" - sec = MySection() - assert sec.some_n == 5 - assert sec.some_str == "foo" - sec.set_from_str("some_n", "42") - assert sec.some_n == 42 - sec.set_from_str("some_str", "bar") - assert sec.some_str == "bar" +def test_set_from_str_type_hint(section_a): + assert section_a.some_n == 42 + assert section_a.some_str == "foo" + section_a.set_from_str("some_n", "5") + assert section_a.some_n == 5 + section_a.set_from_str("some_str", "bar") + assert section_a.some_str == "bar" def test_with_str_mutable_protected(): From 7e278cdf43092b67f5643a4b8169afcb07cffb1e Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 02:24:26 +0100 Subject: [PATCH 08/39] Remove unused import and field in test_base --- tests/test_base.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_base.py b/tests/test_base.py index 9736fa5..a4d5f75 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,6 +1,5 @@ from __future__ import annotations from dataclasses import dataclass -from pathlib import Path import pytest from typing import Optional @@ -40,7 +39,6 @@ def test_set_from_str_type_hint(section_a): def test_with_str_mutable_protected(): @dataclass class MySection(Section): - outdir: Path = Entry(from_str=Path, to_str=str).with_str(".") some_mut: MyMut = Entry(from_str=MyMut.from_str).with_str("4.5,3.8") MySection().some_mut.inner_list.append(5.6) From f9eec00f604ec6c7a0d444db522e9cbf8868d42e Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 02:28:28 +0100 Subject: [PATCH 09/39] Put a Path in tests --- tests/conftest.py | 3 ++- tests/test_base.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 398da65..c76be05 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from pathlib import Path import pytest @@ -79,7 +80,7 @@ def section_a() -> SectionA: @dataclass class SectionB(loam.base.Section): - some_n: int = 42 + some_path: Path = loam.base.Entry(to_str=str).with_val(Path()) some_str: str = "bar" diff --git a/tests/test_base.py b/tests/test_base.py index a4d5f75..087757e 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,5 +1,6 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path import pytest from typing import Optional @@ -78,6 +79,6 @@ class MySection(Section): def test_config_default(my_config): assert my_config.section_a.some_n == 42 - assert my_config.section_b.some_n == 42 + assert my_config.section_b.some_path == Path() assert my_config.section_a.some_str == "foo" assert my_config.section_b.some_str == "bar" From 76831b0e776a41b3a70d8bd093eb314da620cec1 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 02:41:12 +0100 Subject: [PATCH 10/39] Section._loam_meta is no longer a field --- loam/base.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/loam/base.py b/loam/base.py index ea157cc..e0c3e2d 100644 --- a/loam/base.py +++ b/loam/base.py @@ -82,18 +82,14 @@ class Section: it, please call the parent implementation. """ - _loam_meta: Dict[str, _Meta] = field( - default_factory=dict, init=False, repr=False, compare=False) - @classmethod def _type_hints(cls) -> Dict[str, Any]: return get_type_hints(cls) def __post_init__(self) -> None: + self._loam_meta: Dict[str, _Meta] = {} thints = self._type_hints() for fld in fields(self): - if fld.name == "_loam_meta": - continue meta = fld.metadata.get("loam_entry", Entry()) thint = thints[fld.name] if not isinstance(thint, type): From e1f738fde6ee3f8a335ace5e6ac8e88754e7c905 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 02:41:39 +0100 Subject: [PATCH 11/39] Add test for writing/reading toml config file --- tests/test_base.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_base.py b/tests/test_base.py index 087757e..9cb63fd 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -82,3 +82,10 @@ def test_config_default(my_config): assert my_config.section_b.some_path == Path() assert my_config.section_a.some_str == "foo" assert my_config.section_b.some_str == "bar" + + +def test_to_from_toml(my_config, tmp_path): + toml_file = tmp_path / "conf.toml" + my_config.to_file(toml_file) + new_config = my_config.from_file(toml_file) + assert my_config == new_config From 681827afc0afae568f955b6bee77722f0b831862 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 03:07:43 +0100 Subject: [PATCH 12/39] Check that fields of a Config are Sections --- loam/base.py | 8 ++++++-- tests/test_base.py | 10 +++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/loam/base.py b/loam/base.py index e0c3e2d..69ab059 100644 --- a/loam/base.py +++ b/loam/base.py @@ -153,11 +153,15 @@ def from_dict( ) -> TConfig: """Create configuration from a dictionary.""" thints = cls._type_hints() - # check all fields are Section, and type hints are resolved to classes sections = {} for fld in fields(cls): + thint = thints[fld.name] + if not (isinstance(thint, type) and issubclass(thint, Section)): + raise TypeError( + f"Could not resolve type hint of {fld.name} to a Section " + f"(got {thint})") section_dict = options.get(fld.name, {}) - sections[fld.name] = thints[fld.name](**section_dict) + sections[fld.name] = thint(**section_dict) return cls(**sections) def to_file(self, path: Union[str, PathLike]) -> None: diff --git a/tests/test_base.py b/tests/test_base.py index 9cb63fd..0dfaeb3 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -5,7 +5,7 @@ import pytest from typing import Optional -from loam.base import Entry, Section +from loam.base import Entry, Section, Config class MyMut: @@ -89,3 +89,11 @@ def test_to_from_toml(my_config, tmp_path): my_config.to_file(toml_file) new_config = my_config.from_file(toml_file) assert my_config == new_config + + +def test_config_with_not_section(): + @dataclass + class MyConfig(Config): + dummy: int = 5 + with pytest.raises(TypeError): + MyConfig.default() From 41945d33c4b5cda2206097e8c6e6941833512515 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 14:54:28 +0100 Subject: [PATCH 13/39] Rename types to parsers and update docstring --- loam/{types.py => parsers.py} | 3 +-- tests/test_types.py | 44 +++++++++++++++++------------------ 2 files changed, 23 insertions(+), 24 deletions(-) rename loam/{types.py => parsers.py} (96%) diff --git a/loam/types.py b/loam/parsers.py similarity index 96% rename from loam/types.py rename to loam/parsers.py index 4f5e228..4636e07 100644 --- a/loam/types.py +++ b/loam/parsers.py @@ -1,7 +1,6 @@ """Parsers for your CLI arguments. -These functions can be used as the `type` argument in -:attr:`~loam.manager.ConfOpt.cmd_kwargs`. +These functions can be used as `from_str` in :attr:`~loam.base.Entry`. """ from __future__ import annotations diff --git a/tests/test_types.py b/tests/test_types.py index 18ee067..ae64480 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,42 +1,42 @@ import pytest -from loam import types +from loam import parsers def test_slice_or_int_parser(): - assert types.slice_or_int_parser("42") == 42 - assert types.slice_or_int_parser(":3") == slice(3) - assert types.slice_or_int_parser("1:3") == slice(1, 3) - assert types.slice_or_int_parser("1:") == slice(1, None) - assert types.slice_or_int_parser("23:54:2") == slice(23, 54, 2) - assert types.slice_or_int_parser("::5") == slice(None, None, 5) + assert parsers.slice_or_int_parser("42") == 42 + assert parsers.slice_or_int_parser(":3") == slice(3) + assert parsers.slice_or_int_parser("1:3") == slice(1, 3) + assert parsers.slice_or_int_parser("1:") == slice(1, None) + assert parsers.slice_or_int_parser("23:54:2") == slice(23, 54, 2) + assert parsers.slice_or_int_parser("::5") == slice(None, None, 5) with pytest.raises(ValueError): - types.slice_or_int_parser("1:2:3:4") + parsers.slice_or_int_parser("1:2:3:4") def test_strict_slice_parser(): with pytest.raises(ValueError): - assert types.strict_slice_parser("42") == 42 - assert types.strict_slice_parser(":3") == slice(3) - assert types.strict_slice_parser("1:3") == slice(1, 3) - assert types.strict_slice_parser("1:") == slice(1, None) - assert types.strict_slice_parser("23:54:2") == slice(23, 54, 2) - assert types.strict_slice_parser("::5") == slice(None, None, 5) + assert parsers.strict_slice_parser("42") == 42 + assert parsers.strict_slice_parser(":3") == slice(3) + assert parsers.strict_slice_parser("1:3") == slice(1, 3) + assert parsers.strict_slice_parser("1:") == slice(1, None) + assert parsers.strict_slice_parser("23:54:2") == slice(23, 54, 2) + assert parsers.strict_slice_parser("::5") == slice(None, None, 5) def test_slice_parser(): - assert types.slice_parser("42") == slice(42) - assert types.slice_parser(":3") == slice(3) - assert types.slice_parser("1:3") == slice(1, 3) - assert types.slice_parser("1:") == slice(1, None) - assert types.slice_parser("23:54:2") == slice(23, 54, 2) - assert types.slice_parser("::5") == slice(None, None, 5) + assert parsers.slice_parser("42") == slice(42) + assert parsers.slice_parser(":3") == slice(3) + assert parsers.slice_parser("1:3") == slice(1, 3) + assert parsers.slice_parser("1:") == slice(1, None) + assert parsers.slice_parser("23:54:2") == slice(23, 54, 2) + assert parsers.slice_parser("::5") == slice(None, None, 5) def test_list_of(): - lfloat = types.list_of(float) + lfloat = parsers.list_of(float) assert lfloat("3.2,4.5,12.8") == (3.2, 4.5, 12.8) assert lfloat("42") == (42.,) assert lfloat("78e4, 12,") == (7.8e5, 12.) assert lfloat("") == tuple() - lint = types.list_of(int, ";") + lint = parsers.list_of(int, ";") assert lint("3;4") == (3, 4) From f6bc9cd97210d2c343a0c83a6c32505db259c4a2 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 16:43:57 +0100 Subject: [PATCH 14/39] Move Section.context from old to new API --- loam/_internal.py | 11 +++++++---- loam/base.py | 12 +++++++++++- loam/manager.py | 11 ++--------- tests/conftest.py | 5 +++++ tests/test_base.py | 14 ++++++++++++++ tests/test_config_opts.py | 8 -------- 6 files changed, 39 insertions(+), 22 deletions(-) diff --git a/loam/_internal.py b/loam/_internal.py index 8ad2806..239daeb 100644 --- a/loam/_internal.py +++ b/loam/_internal.py @@ -10,7 +10,7 @@ if typing.TYPE_CHECKING: from typing import Dict, Any, Tuple, Mapping, Optional, Type from argparse import ArgumentParser, Namespace - from .manager import Section + from .base import Section class Switch(argparse.Action): @@ -48,12 +48,15 @@ def __init__(self, section: Section, options: Mapping[str, Any]): def __enter__(self) -> None: self._old_values = {} for option_name, new_value in self._options.items(): - self._old_values[option_name] = self._section[option_name] - self._section[option_name] = new_value + self._old_values[option_name] = getattr(self._section, option_name) + if isinstance(new_value, str): + self._section.set_from_str(option_name, new_value) + else: + setattr(self._section, option_name, new_value) def __exit__(self, e_type: Optional[Type[BaseException]], *_: Any) -> bool: for option_name, old_value in self._old_values.items(): - self._section[option_name] = old_value + setattr(self._section, option_name, old_value) return e_type is None diff --git a/loam/base.py b/loam/base.py index 69ab059..63afc5a 100644 --- a/loam/base.py +++ b/loam/base.py @@ -7,11 +7,14 @@ from pathlib import Path from typing import ( get_type_hints, - TypeVar, Generic, Callable, Optional, Dict, Any, Type, Union, Mapping + TypeVar, Generic, Callable, Optional, Dict, Any, Type, Union, Mapping, + ContextManager ) import toml +from . import _internal + T = TypeVar("T") @@ -124,6 +127,13 @@ def set_from_str(self, field_name: str, value_as_str: str) -> None: f"Please specify a `from_str` for {field_name}.") setattr(self, field_name, value) + def context(self, **options: Any) -> ContextManager[None]: + """Enter a context with locally changed option values. + + This context is reusable but not reentrant. + """ + return _internal.SectionContext(self, options) + TConfig = TypeVar("TConfig", bound="Config") diff --git a/loam/manager.py b/loam/manager.py index 6377eca..d318a8b 100644 --- a/loam/manager.py +++ b/loam/manager.py @@ -14,11 +14,11 @@ import toml -from . import error, _internal +from . import error if typing.TYPE_CHECKING: from typing import (Dict, List, Any, Union, Tuple, Mapping, Optional, - Iterator, Iterable, ContextManager) + Iterator, Iterable) from os import PathLike @@ -139,13 +139,6 @@ def reset_(self) -> None: for opt, meta in self.defaults_(): self[opt] = meta.default - def context_(self, **options: Any) -> ContextManager[None]: - """Enter a context to locally change option values. - - This context is reusable but not reentrant. - """ - return _internal.SectionContext(self, options) - class ConfigurationManager: """Configuration manager. diff --git a/tests/conftest.py b/tests/conftest.py index c76be05..6dde9f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,6 +84,11 @@ class SectionB(loam.base.Section): some_str: str = "bar" +@pytest.fixture +def section_b() -> SectionB: + return SectionB() + + @dataclass class MyConfig(loam.base.Config): section_a: SectionA diff --git a/tests/test_base.py b/tests/test_base.py index 0dfaeb3..046fd11 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -37,6 +37,20 @@ def test_set_from_str_type_hint(section_a): assert section_a.some_str == "bar" +def test_context(section_a): + with section_a.context(some_n=5, some_str="bar"): + assert section_a.some_n == 5 + assert section_a.some_str == "bar" + assert section_a.some_n == 42 + assert section_a.some_str == "foo" + + +def test_context_from_str(section_b): + with section_b.context(some_path="my/path"): + assert section_b.some_path == Path("my/path") + assert section_b.some_path == Path() + + def test_with_str_mutable_protected(): @dataclass class MySection(Section): diff --git a/tests/test_config_opts.py b/tests/test_config_opts.py index 099c9ec..cf8c938 100644 --- a/tests/test_config_opts.py +++ b/tests/test_config_opts.py @@ -170,11 +170,3 @@ def test_config_iter_subconfig_default_val(conf): vals_iter = set(conf.sectionA.opt_vals_()) vals_dflts = set((o, m.default) for o, m in conf.sectionA.defaults_()) assert vals_iter == vals_dflts - - -def test_context_section(conf): - with conf.sectionA.context_(optA=42, optBool=False): - assert conf.sectionA.optA == 42 - assert conf.sectionA.optBool is False - assert conf.sectionA.optA == 1 - assert conf.sectionA.optBool is True From 99f0eef4e6b873932a19861ab3905e6ace87d918 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 17:05:24 +0100 Subject: [PATCH 15/39] Append _ to Section and Config methods This avoids name-clash with user-defined fields in their config options. --- loam/_internal.py | 2 +- loam/base.py | 18 +++++++++--------- tests/conftest.py | 2 +- tests/test_base.py | 16 ++++++++-------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/loam/_internal.py b/loam/_internal.py index 239daeb..7d4c0fc 100644 --- a/loam/_internal.py +++ b/loam/_internal.py @@ -50,7 +50,7 @@ def __enter__(self) -> None: for option_name, new_value in self._options.items(): self._old_values[option_name] = getattr(self._section, option_name) if isinstance(new_value, str): - self._section.set_from_str(option_name, new_value) + self._section.set_from_str_(option_name, new_value) else: setattr(self._section, option_name, new_value) diff --git a/loam/base.py b/loam/base.py index 63afc5a..555a6e8 100644 --- a/loam/base.py +++ b/loam/base.py @@ -101,14 +101,14 @@ def __post_init__(self) -> None: current_val = getattr(self, fld.name) if (not issubclass(thint, str)) and isinstance(current_val, str): - self.set_from_str(fld.name, current_val) + self.set_from_str_(fld.name, current_val) current_val = getattr(self, fld.name) if not isinstance(current_val, thint): typ = type(current_val) raise TypeError( f"Expected a {thint} for {fld.name}, received a {typ}.") - def set_from_str(self, field_name: str, value_as_str: str) -> None: + def set_from_str_(self, field_name: str, value_as_str: str) -> None: """Set an option from the string representation of the value. This uses :meth:`Entry.from_str` to parse the given string, and @@ -127,7 +127,7 @@ def set_from_str(self, field_name: str, value_as_str: str) -> None: f"Please specify a `from_str` for {field_name}.") setattr(self, field_name, value) - def context(self, **options: Any) -> ContextManager[None]: + def context_(self, **options: Any) -> ContextManager[None]: """Enter a context with locally changed option values. This context is reusable but not reentrant. @@ -143,22 +143,22 @@ class Config: """Base class for a full configuration.""" @classmethod - def default(cls: Type[TConfig]) -> TConfig: + def default_(cls: Type[TConfig]) -> TConfig: """Create a configuration with default values.""" - return cls.from_dict({}) + return cls.from_dict_({}) @classmethod - def from_file(cls: Type[TConfig], path: Union[str, PathLike]) -> TConfig: + def from_file_(cls: Type[TConfig], path: Union[str, PathLike]) -> TConfig: """Read configuration from toml file.""" pars = toml.load(Path(path)) - return cls.from_dict(pars) + return cls.from_dict_(pars) @classmethod def _type_hints(cls) -> Dict[str, Any]: return get_type_hints(cls) @classmethod - def from_dict( + def from_dict_( cls: Type[TConfig], options: Mapping[str, Mapping[str, Any]] ) -> TConfig: """Create configuration from a dictionary.""" @@ -174,7 +174,7 @@ def from_dict( sections[fld.name] = thint(**section_dict) return cls(**sections) - def to_file(self, path: Union[str, PathLike]) -> None: + def to_file_(self, path: Union[str, PathLike]) -> None: """Write configuration in toml file.""" dct = asdict(self) for sec_name, sec_dict in dct.items(): diff --git a/tests/conftest.py b/tests/conftest.py index 6dde9f6..e4158a6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -97,4 +97,4 @@ class MyConfig(loam.base.Config): @pytest.fixture def my_config() -> MyConfig: - return MyConfig.default() + return MyConfig.default_() diff --git a/tests/test_base.py b/tests/test_base.py index 046fd11..094c178 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -31,14 +31,14 @@ class MySection(Section): def test_set_from_str_type_hint(section_a): assert section_a.some_n == 42 assert section_a.some_str == "foo" - section_a.set_from_str("some_n", "5") + section_a.set_from_str_("some_n", "5") assert section_a.some_n == 5 - section_a.set_from_str("some_str", "bar") + section_a.set_from_str_("some_str", "bar") assert section_a.some_str == "bar" def test_context(section_a): - with section_a.context(some_n=5, some_str="bar"): + with section_a.context_(some_n=5, some_str="bar"): assert section_a.some_n == 5 assert section_a.some_str == "bar" assert section_a.some_n == 42 @@ -46,7 +46,7 @@ def test_context(section_a): def test_context_from_str(section_b): - with section_b.context(some_path="my/path"): + with section_b.context_(some_path="my/path"): assert section_b.some_path == Path("my/path") assert section_b.some_path == Path() @@ -88,7 +88,7 @@ class MySection(Section): sec = MySection() assert sec.my_mut.inner_list == [4.5] with pytest.raises(ValueError): - sec.set_from_str("my_mut", "4.5,3.8") + sec.set_from_str_("my_mut", "4.5,3.8") def test_config_default(my_config): @@ -100,8 +100,8 @@ def test_config_default(my_config): def test_to_from_toml(my_config, tmp_path): toml_file = tmp_path / "conf.toml" - my_config.to_file(toml_file) - new_config = my_config.from_file(toml_file) + my_config.to_file_(toml_file) + new_config = my_config.from_file_(toml_file) assert my_config == new_config @@ -110,4 +110,4 @@ def test_config_with_not_section(): class MyConfig(Config): dummy: int = 5 with pytest.raises(TypeError): - MyConfig.default() + MyConfig.default_() From a65160798463481d2e29c25ecb0410111ba654ff Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 17:55:00 +0100 Subject: [PATCH 16/39] Scrap set_conf_opt and set_conf_str These are seldom used in practice but are hard to get right. --- loam/tools.py | 50 ++------------------------------------------- tests/test_tools.py | 7 ------- 2 files changed, 2 insertions(+), 55 deletions(-) delete mode 100644 tests/test_tools.py diff --git a/loam/tools.py b/loam/tools.py index 8ed7d74..e3fff45 100644 --- a/loam/tools.py +++ b/loam/tools.py @@ -9,11 +9,11 @@ import shlex import typing -from . import error, _internal +from . import _internal from .manager import ConfOpt if typing.TYPE_CHECKING: - from typing import Optional, Dict, List, Union + from typing import Optional, Dict, Union from os import PathLike from .manager import ConfigurationManager from .cli import CLIManager @@ -75,52 +75,6 @@ def config_conf_section() -> Dict[str, ConfOpt]: ) -def set_conf_opt(shortname: Optional[str] = None) -> ConfOpt: - """Define a Confopt to set a config option. - - You can feed the value of this option to :func:`set_conf_str`. - - Args: - shortname: shortname for the option if relevant. - - Returns: - the option definition. - """ - return ConfOpt(None, True, shortname, - dict(action='append', metavar='section.option=value'), - False, 'set configuration options') - - -def set_conf_str(conf: ConfigurationManager, optstrs: List[str]) -> None: - """Set options from a list of section.option=value string. - - Args: - conf: the :class:`~loam.manager.ConfigurationManager` to update. - optstrs: the list of 'section.option=value' formatted strings. - """ - falsy = ['0', 'no', 'n', 'off', 'false', 'f'] - bool_actions = ['store_true', 'store_false', _internal.Switch] - for optstr in optstrs: - opt, val = optstr.split('=', 1) - sec, opt = opt.split('.', 1) - if sec not in conf: - raise error.SectionError(sec) - if opt not in conf[sec]: - raise error.OptionError(opt) - meta = conf[sec].def_[opt] - if meta.default is None: - if 'type' in meta.cmd_kwargs: - cast = meta.cmd_kwargs['type'] - else: - act = meta.cmd_kwargs.get('action') - cast = bool if act in bool_actions else str - else: - cast = type(meta.default) - if cast is bool and val.lower() in falsy: - val = '' - conf[sec][opt] = cast(val) - - def config_cmd_handler(conf: ConfigurationManager, config: str = 'config') -> None: """Implement the behavior of a subcmd using config_conf_section. diff --git a/tests/test_tools.py b/tests/test_tools.py deleted file mode 100644 index aae90c3..0000000 --- a/tests/test_tools.py +++ /dev/null @@ -1,7 +0,0 @@ -import loam.tools - - -def test_set_conf_str(conf): - loam.tools.set_conf_str(conf, ['sectionB.optA=42', 'sectionA.optBool=f']) - assert conf.sectionB.optA == 42 - assert conf.sectionA.optBool is False From 40400f9274cd5632aa0968d0e6711c74f2a8990c Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 19:32:26 +0100 Subject: [PATCH 17/39] New exist_ok parameter of Config.to_file_ --- loam/base.py | 10 ++++++++-- tests/test_base.py | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/loam/base.py b/loam/base.py index 555a6e8..b8d1909 100644 --- a/loam/base.py +++ b/loam/base.py @@ -174,13 +174,19 @@ def from_dict_( sections[fld.name] = thint(**section_dict) return cls(**sections) - def to_file_(self, path: Union[str, PathLike]) -> None: + def to_file_( + self, path: Union[str, PathLike], exist_ok: bool = True + ) -> None: """Write configuration in toml file.""" + path = Path(path) + if not exist_ok and path.is_file(): + raise RuntimeError(f"{path} already exists") + path.parent.mkdir(parents=True, exist_ok=True) dct = asdict(self) for sec_name, sec_dict in dct.items(): for fld in fields(getattr(self, sec_name)): entry: Entry = fld.metadata.get("loam_entry", Entry()) if entry.to_str is not None: sec_dict[fld.name] = entry.to_str(sec_dict[fld.name]) - with Path(path).open('w') as pf: + with path.open('w') as pf: toml.dump(dct, pf) diff --git a/tests/test_base.py b/tests/test_base.py index 094c178..dca04c9 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -105,6 +105,14 @@ def test_to_from_toml(my_config, tmp_path): assert my_config == new_config +def test_to_file_exist_ok(my_config, tmp_path): + toml_file = tmp_path / "conf.toml" + my_config.to_file_(toml_file) + with pytest.raises(RuntimeError): + my_config.to_file_(toml_file, exist_ok=False) + my_config.to_file_(toml_file) + + def test_config_with_not_section(): @dataclass class MyConfig(Config): From 1922f876766262868f73d613a1937ea268778715 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 19:33:52 +0100 Subject: [PATCH 18/39] from_dict_ and from_file_ replaced by update_* Acting on the instance instead of the class is more flexible. --- loam/_internal.py | 14 +++++--------- loam/base.py | 43 ++++++++++++++++++++++++++----------------- tests/test_base.py | 5 ++++- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/loam/_internal.py b/loam/_internal.py index 7d4c0fc..80c1b35 100644 --- a/loam/_internal.py +++ b/loam/_internal.py @@ -46,17 +46,13 @@ def __init__(self, section: Section, options: Mapping[str, Any]): self._old_values: Dict[str, Any] = {} def __enter__(self) -> None: - self._old_values = {} - for option_name, new_value in self._options.items(): - self._old_values[option_name] = getattr(self._section, option_name) - if isinstance(new_value, str): - self._section.set_from_str_(option_name, new_value) - else: - setattr(self._section, option_name, new_value) + self._old_values = { + opt: getattr(self._section, opt) for opt in self._options + } + self._section.update_from_dict_(self._options) def __exit__(self, e_type: Optional[Type[BaseException]], *_: Any) -> bool: - for option_name, old_value in self._old_values.items(): - setattr(self._section, option_name, old_value) + self._section.update_from_dict_(self._old_values) return e_type is None diff --git a/loam/base.py b/loam/base.py index b8d1909..99796ed 100644 --- a/loam/base.py +++ b/loam/base.py @@ -134,6 +134,14 @@ def context_(self, **options: Any) -> ContextManager[None]: """ return _internal.SectionContext(self, options) + def update_from_dict_(self, options: Mapping[str, Any]) -> None: + """Update options from a mapping, parsing str as needed.""" + for opt, val in options.items(): + if isinstance(val, str): + self.set_from_str_(opt, val) + else: + setattr(self, opt, val) + TConfig = TypeVar("TConfig", bound="Config") @@ -142,26 +150,13 @@ def context_(self, **options: Any) -> ContextManager[None]: class Config: """Base class for a full configuration.""" - @classmethod - def default_(cls: Type[TConfig]) -> TConfig: - """Create a configuration with default values.""" - return cls.from_dict_({}) - - @classmethod - def from_file_(cls: Type[TConfig], path: Union[str, PathLike]) -> TConfig: - """Read configuration from toml file.""" - pars = toml.load(Path(path)) - return cls.from_dict_(pars) - @classmethod def _type_hints(cls) -> Dict[str, Any]: return get_type_hints(cls) @classmethod - def from_dict_( - cls: Type[TConfig], options: Mapping[str, Mapping[str, Any]] - ) -> TConfig: - """Create configuration from a dictionary.""" + def default_(cls: Type[TConfig]) -> TConfig: + """Create a configuration with default values.""" thints = cls._type_hints() sections = {} for fld in fields(cls): @@ -170,10 +165,23 @@ def from_dict_( raise TypeError( f"Could not resolve type hint of {fld.name} to a Section " f"(got {thint})") - section_dict = options.get(fld.name, {}) - sections[fld.name] = thint(**section_dict) + sections[fld.name] = thint() return cls(**sections) + def update_from_file_(self, path: Union[str, PathLike]) -> None: + """Read configuration from toml file.""" + pars = toml.load(Path(path)) + # filter out options for which in_file is False + self.update_from_dict_(pars) + + def update_from_dict_( + self, options: Mapping[str, Mapping[str, Any]] + ) -> None: + """Create configuration from a dictionary.""" + for sec, opts in options.items(): + section: Section = getattr(self, sec) + section.update_from_dict_(opts) + def to_file_( self, path: Union[str, PathLike], exist_ok: bool = True ) -> None: @@ -186,6 +194,7 @@ def to_file_( for sec_name, sec_dict in dct.items(): for fld in fields(getattr(self, sec_name)): entry: Entry = fld.metadata.get("loam_entry", Entry()) + # only write down those that are in_file if entry.to_str is not None: sec_dict[fld.name] = entry.to_str(sec_dict[fld.name]) with path.open('w') as pf: diff --git a/tests/test_base.py b/tests/test_base.py index dca04c9..4ec065f 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -100,8 +100,11 @@ def test_config_default(my_config): def test_to_from_toml(my_config, tmp_path): toml_file = tmp_path / "conf.toml" + my_config.section_a.some_n = 5 + my_config.section_b.some_path = Path("foo/bar") my_config.to_file_(toml_file) - new_config = my_config.from_file_(toml_file) + new_config = my_config.default_() + new_config.update_from_file_(toml_file) assert my_config == new_config From 5febf7da119a3e4c99f96f03df0a62e8a59bca3a Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 20:04:04 +0100 Subject: [PATCH 19/39] Update docstrings --- loam/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/loam/base.py b/loam/base.py index 99796ed..4051729 100644 --- a/loam/base.py +++ b/loam/base.py @@ -169,7 +169,7 @@ def default_(cls: Type[TConfig]) -> TConfig: return cls(**sections) def update_from_file_(self, path: Union[str, PathLike]) -> None: - """Read configuration from toml file.""" + """Update configuration from toml file.""" pars = toml.load(Path(path)) # filter out options for which in_file is False self.update_from_dict_(pars) @@ -177,7 +177,7 @@ def update_from_file_(self, path: Union[str, PathLike]) -> None: def update_from_dict_( self, options: Mapping[str, Mapping[str, Any]] ) -> None: - """Create configuration from a dictionary.""" + """Update configuration from a dictionary.""" for sec, opts in options.items(): section: Section = getattr(self, sec) section.update_from_dict_(opts) From 1e297a0de068461b1bac16f14e679053261d057d Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 20:04:13 +0100 Subject: [PATCH 20/39] Make base.Meta part of the public API Information for a given entry is exposed via Config.meta_ --- loam/base.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/loam/base.py b/loam/base.py index 4051729..e91e5a0 100644 --- a/loam/base.py +++ b/loam/base.py @@ -69,8 +69,15 @@ def with_factory(self, func: Callable[[], T]) -> T: @dataclass(frozen=True) -class _Meta(Generic[T]): - """Group several metadata.""" +class Meta(Generic[T]): + """Group several metadata of configuration entry. + + Attributes: + fld: :class:`dataclasses.Field` object from the underlying metadata. + entry: the metadata from the loam API. + type_hint: type hint resolved as a class. If the type hint could not + be resolved as a class, this is merely :class:`object`. + """ fld: Field[T] entry: Entry[T] @@ -90,14 +97,14 @@ def _type_hints(cls) -> Dict[str, Any]: return get_type_hints(cls) def __post_init__(self) -> None: - self._loam_meta: Dict[str, _Meta] = {} + self._loam_meta: Dict[str, Meta] = {} thints = self._type_hints() for fld in fields(self): meta = fld.metadata.get("loam_entry", Entry()) thint = thints[fld.name] if not isinstance(thint, type): thint = object - self._loam_meta[fld.name] = _Meta(fld, meta, thint) + self._loam_meta[fld.name] = Meta(fld, meta, thint) current_val = getattr(self, fld.name) if (not issubclass(thint, str)) and isinstance(current_val, str): @@ -108,6 +115,10 @@ def __post_init__(self) -> None: raise TypeError( f"Expected a {thint} for {fld.name}, received a {typ}.") + def meta_(self, entry_name: str) -> Meta: + """Metadata for the given entry name.""" + return self._loam_meta[entry_name] + def set_from_str_(self, field_name: str, value_as_str: str) -> None: """Set an option from the string representation of the value. From 51fbdfdfd507c85c538f1d0d0d0fc7b20b736484 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Mon, 25 Apr 2022 20:31:25 +0100 Subject: [PATCH 21/39] Honor Entry.in_file when writing/reading file --- loam/base.py | 26 ++++++++++++++++++++------ tests/conftest.py | 2 +- tests/test_base.py | 18 +++++++++++++++++- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/loam/base.py b/loam/base.py index e91e5a0..8cc23df 100644 --- a/loam/base.py +++ b/loam/base.py @@ -182,7 +182,15 @@ def default_(cls: Type[TConfig]) -> TConfig: def update_from_file_(self, path: Union[str, PathLike]) -> None: """Update configuration from toml file.""" pars = toml.load(Path(path)) - # filter out options for which in_file is False + # only keep entries for which in_file is True + pars = { + sec_name: { + opt: val + for opt, val in section.items() + if getattr(self, sec_name).meta_(opt).entry.in_file + } + for sec_name, section in pars.items() + } self.update_from_dict_(pars) def update_from_dict_( @@ -202,11 +210,17 @@ def to_file_( raise RuntimeError(f"{path} already exists") path.parent.mkdir(parents=True, exist_ok=True) dct = asdict(self) + to_dump: Dict[str, Dict[str, Any]] = {} for sec_name, sec_dict in dct.items(): - for fld in fields(getattr(self, sec_name)): - entry: Entry = fld.metadata.get("loam_entry", Entry()) - # only write down those that are in_file + to_dump[sec_name] = {} + section: Section = getattr(self, sec_name) + for fld in fields(section): + entry = section.meta_(fld.name).entry + if not entry.in_file: + continue + value = sec_dict[fld.name] if entry.to_str is not None: - sec_dict[fld.name] = entry.to_str(sec_dict[fld.name]) + value = entry.to_str(value) + to_dump[sec_name][fld.name] = value with path.open('w') as pf: - toml.dump(dct, pf) + toml.dump(to_dump, pf) diff --git a/tests/conftest.py b/tests/conftest.py index e4158a6..7188777 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -81,7 +81,7 @@ def section_a() -> SectionA: @dataclass class SectionB(loam.base.Section): some_path: Path = loam.base.Entry(to_str=str).with_val(Path()) - some_str: str = "bar" + some_str: str = loam.base.Entry(in_file=False).with_val("bar") @pytest.fixture diff --git a/tests/test_base.py b/tests/test_base.py index 4ec065f..4d39418 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,9 +1,10 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path +from typing import Optional import pytest -from typing import Optional +import toml from loam.base import Entry, Section, Config @@ -108,6 +109,21 @@ def test_to_from_toml(my_config, tmp_path): assert my_config == new_config +def test_to_toml_not_in_file(my_config, tmp_path): + toml_file = tmp_path / "conf.toml" + my_config.section_b.some_str = "ignored" + my_config.to_file_(toml_file) + assert "ignored" not in toml_file.read_text() + + +def test_from_toml_not_in_file(my_config, tmp_path): + toml_file = tmp_path / "conf.toml" + with toml_file.open("w") as tf: + toml.dump({"section_b": {"some_str": "ignored"}}, tf) + my_config.default_().update_from_file_(toml_file) + assert my_config.section_b.some_str == "bar" + + def test_to_file_exist_ok(my_config, tmp_path): toml_file = tmp_path / "conf.toml" my_config.to_file_(toml_file) From 97bd786dceba66a1c4175e63aea97521b5cd5497 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Tue, 26 Apr 2022 00:12:37 +0100 Subject: [PATCH 22/39] Change Entry API to make type check robust Prior to this change, mypy would trip over Entry().with_val(foo) since it couldn't guess the Entry type. Defining val (and similar) as a field of Entry means the latter is written as Entry(val=foo).field(), which mypy understands correctly. --- loam/base.py | 50 +++++++++++++++++++++++++++------------------- tests/conftest.py | 4 ++-- tests/test_base.py | 17 +++++++++++----- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/loam/base.py b/loam/base.py index 8cc23df..7c034b3 100644 --- a/loam/base.py +++ b/loam/base.py @@ -23,6 +23,13 @@ class Entry(Generic[T]): """Metadata of configuration options. Attributes: + val: default value. Use :attr`val_str` or :attr:`val_factory` instead + if it is mutable. + val_str: default value from a string representation. This requires + :attr:`from_str`. The call to the latter is wrapped in a function + to avoid issues if the obtained value is mutable. + val_factory: default value wrapped in a function, this is + useful if the default value is mutable. doc: short description of the option. in_file: whether the option can be set in the config file. in_cli: whether the option is a command line argument. @@ -33,6 +40,9 @@ class Entry(Generic[T]): cli_zsh_comprule: completion rule for ZSH shell. """ + val: Optional[T] = None + val_str: Optional[str] = None + val_factory: Optional[Callable[[], T]] = None doc: str = "" from_str: Optional[Callable[[str], T]] = None to_str: Optional[Callable[[T], str]] = None @@ -42,29 +52,27 @@ class Entry(Generic[T]): cli_kwargs: Dict[str, Any] = field(default_factory=dict) cli_zsh_comprule: Optional[str] = '' - def with_str(self, val_as_str: str) -> T: - """Set default value from a string representation. - - This uses :attr:`from_str`. Note that the call itself is embedded in a - factory function to avoid issues if the generated value is mutable. - """ - if self.from_str is None: - raise ValueError("Need `from_str` to call with_str") - func = self.from_str # for mypy to see func is not None here - return self.with_factory(lambda: func(val_as_str)) - - def with_val(self, val: T) -> T: - """Set default value. - - Use :meth:`with_factory` or :meth:`with_str` if the value is mutable. - """ - return field(default=val, metadata=dict(loam_entry=self)) + def field(self) -> T: + """Produce a :class:`dataclasses.Field` from the entry.""" + non_none_cout = (int(self.val is not None) + + int(self.val_str is not None) + + int(self.val_factory is not None)) + if non_none_cout != 1: + raise ValueError( + "Exactly one of val, val_str, and val_factory should be set.") + + if self.val is not None: + return field(default=self.val, metadata=dict(loam_entry=self)) + if self.val_factory is not None: + func = self.val_factory + else: + if self.from_str is None: + raise ValueError("Need `from_str` to use val_str") - def with_factory(self, func: Callable[[], T]) -> T: - """Set default value from a factory function. + def func() -> T: + # TYPE SAFETY: previous checks ensure this is valid + return self.from_str(self.val_str) # type: ignore - This is useful with the value is mutable. - """ return field(default_factory=func, metadata=dict(loam_entry=self)) diff --git a/tests/conftest.py b/tests/conftest.py index 7188777..8e59ddb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -80,8 +80,8 @@ def section_a() -> SectionA: @dataclass class SectionB(loam.base.Section): - some_path: Path = loam.base.Entry(to_str=str).with_val(Path()) - some_str: str = loam.base.Entry(in_file=False).with_val("bar") + some_path: Path = loam.base.Entry(val=Path(), to_str=str).field() + some_str: str = loam.base.Entry(val="bar", in_file=False).field() @pytest.fixture diff --git a/tests/test_base.py b/tests/test_base.py index 4d39418..288a1e3 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -23,12 +23,17 @@ def from_str(s: str) -> MyMut: def test_with_val(): @dataclass class MySection(Section): - some_n: int = Entry().with_val(42) + some_n: int = Entry(val=42).field() sec = MySection() assert sec.some_n == 42 +def test_two_vals_fail(): + with pytest.raises(ValueError): + Entry(val=5, val_factory=lambda: 5).field() + + def test_set_from_str_type_hint(section_a): assert section_a.some_n == 42 assert section_a.some_str == "foo" @@ -55,7 +60,8 @@ def test_context_from_str(section_b): def test_with_str_mutable_protected(): @dataclass class MySection(Section): - some_mut: MyMut = Entry(from_str=MyMut.from_str).with_str("4.5,3.8") + some_mut: MyMut = Entry( + val_str="4.5,3.8", from_str=MyMut.from_str).field() MySection().some_mut.inner_list.append(5.6) assert MySection().some_mut.inner_list == [4.5, 3.8] @@ -64,14 +70,15 @@ class MySection(Section): def test_type_hint_not_a_class(): @dataclass class MySection(Section): - maybe_n: Optional[int] = Entry(from_str=int).with_val(None) + maybe_n: Optional[int] = Entry( + val_factory=lambda: None, from_str=int).field() assert MySection().maybe_n is None assert MySection("42").maybe_n == 42 def test_with_str_no_from_str(): with pytest.raises(ValueError): - Entry().with_str("5") + Entry(val_str="5").field() def test_init_wrong_type(): @@ -85,7 +92,7 @@ class MySection(Section): def test_missing_from_str(): @dataclass class MySection(Section): - my_mut: MyMut = Entry().with_factory(lambda: MyMut([4.5])) + my_mut: MyMut = Entry(val_factory=lambda: MyMut([4.5])).field() sec = MySection() assert sec.my_mut.inner_list == [4.5] with pytest.raises(ValueError): From d4ff43e4f4b3f211f76c479819f1e41a7d941cf1 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Tue, 26 Apr 2022 00:59:39 +0100 Subject: [PATCH 23/39] Tools related to ConfigSection use the new API --- loam/tools.py | 77 ++++++++++++++++++++++++--------------------------- 1 file changed, 36 insertions(+), 41 deletions(-) diff --git a/loam/tools.py b/loam/tools.py index e3fff45..b319090 100644 --- a/loam/tools.py +++ b/loam/tools.py @@ -1,21 +1,20 @@ -"""Various helper functions and classes. - -They are designed to help you use :class:`~loam.manager.ConfigurationManager`. -""" +"""Various helper functions and classes.""" from __future__ import annotations +from dataclasses import dataclass import pathlib import subprocess import shlex import typing from . import _internal +from .base import Entry, Section, Config from .manager import ConfOpt if typing.TYPE_CHECKING: - from typing import Optional, Dict, Union + from pathlib import Path + from typing import Optional, Union, Type from os import PathLike - from .manager import ConfigurationManager from .cli import CLIManager @@ -39,7 +38,7 @@ def switch_opt(default: bool, shortname: Optional[str], dict(action=_internal.Switch), True, help_msg, None) -def command_flag(shortname: Optional[str], help_msg: str) -> ConfOpt: +def command_flag(doc: str, shortname: Optional[str] = None) -> bool: """Define a command line flag. The corresponding option is set to true if it is passed as a command line @@ -48,52 +47,48 @@ def command_flag(shortname: Optional[str], help_msg: str) -> ConfOpt: switch it off from the command line. Args: + doc: short description of the option. shortname: short name of the option, no shortname will be used if set to None. - help_msg: short description of the option. - - Returns: - a :class:`~loam.manager.ConfOpt` with the relevant properties. """ - return ConfOpt(None, True, shortname, dict(action='store_true'), False, - help_msg, None) + return Entry( # previously, default value was None. Diff in cli? + val=False, doc=doc, in_file=False, cli_short=shortname, + cli_kwargs=dict(action="store_true"), cli_zsh_comprule=None + ).field() -def config_conf_section() -> Dict[str, ConfOpt]: - """Define a configuration section handling config file. +@dataclass +class ConfigSection(Section): + """A configuration section handling config files.""" - Returns: - definition of the 'create', 'create_local', 'update', 'edit' and - 'editor' configuration options. - """ - return dict( - create=command_flag(None, 'create most global config file'), - create_local=command_flag(None, 'create most local config file'), - update=command_flag(None, 'add missing entries to config file'), - edit=command_flag(None, 'open config file in a text editor'), - editor=ConfOpt('vim', conf_arg=True, help='text editor'), - ) + create: bool = command_flag("create global config file") + update: bool = command_flag("add missing entries to config file") + edit: bool = command_flag("open config file in a text editor") + editor: str = Entry(val="vim", doc='text editor').field() -def config_cmd_handler(conf: ConfigurationManager, - config: str = 'config') -> None: +def config_cmd_handler( + config: Union[Config, Type[Config]], + config_section: ConfigSection, + config_file: Path, +) -> None: """Implement the behavior of a subcmd using config_conf_section. Args: - conf: a :class:`~loam.manager.ConfigurationManager` containing a - section created with :func:`config_conf_section` function. - config: name of the configuration section created with - :func:`config_conf_section` function. + config: the :class:`~loam.base.Config` to manage. + config_section: a :class:`ConfigSection` set as desired. + config_file: path to the config file. """ - if conf[config].create or conf[config].update: - conf.create_config_(update=conf[config].update) - if conf[config].create_local: - conf.create_config_(index=-1, update=conf[config].update) - if conf[config].edit: - if not conf.config_files_[0].is_file(): - conf.create_config_(update=conf[config].update) - subprocess.run(shlex.split('{} {}'.format(conf[config].editor, - conf.config_files_[0]))) + if config_section.update: + conf = config.default_() + if config_file.exists(): + conf.update_from_file_(config_file) + conf.to_file_(config_file) + elif config_section.create or config_section.edit: + config.default_().to_file_(config_file) + if config_section.edit: + subprocess.run(shlex.split('{} {}'.format(config_section.editor, + config_file))) def create_complete_files(climan: CLIManager, path: Union[str, PathLike], From 42a9fd4cd083a1c17de23f138ac6f76963dd6522 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Tue, 26 Apr 2022 03:07:31 +0100 Subject: [PATCH 24/39] Update remaining code to new API --- loam/cli.py | 80 +++++---- loam/manager.py | 369 -------------------------------------- loam/tools.py | 16 +- tests/conftest.py | 71 ++++---- tests/test_base.py | 11 ++ tests/test_config_file.py | 61 +------ tests/test_config_opts.py | 172 ------------------ tests/test_parser.py | 3 +- 8 files changed, 99 insertions(+), 684 deletions(-) delete mode 100644 loam/manager.py delete mode 100644 tests/test_config_opts.py diff --git a/loam/cli.py b/loam/cli.py index 2c2ddfa..d7bc6f0 100644 --- a/loam/cli.py +++ b/loam/cli.py @@ -1,5 +1,6 @@ """Definition of CLI manager.""" from __future__ import annotations +from dataclasses import fields import argparse import copy import pathlib @@ -13,7 +14,7 @@ from typing import Dict, List, Any, Optional, Mapping, TextIO, Union from argparse import ArgumentParser, Namespace from os import PathLike - from .manager import Section, ConfigurationManager + from .base import Section, Config BLK = ' \\\n' # cutting line in scripts @@ -21,17 +22,19 @@ def _names(section: Section, option: str) -> List[str]: """List of cli strings for a given option.""" - meta = section.def_[option] - action = meta.cmd_kwargs.get('action') + entry = section.meta_(option).entry + action = entry.cli_kwargs.get('action') if action is _internal.Switch: names = [f'-{option}', f'+{option}'] - if meta.shortname is not None: - names.append(f'-{meta.shortname}') - names.append(f'+{meta.shortname}') + short = entry.cli_short + if short is not None: + names.append(f'-{short}') + names.append(f'+{short}') else: names = [f'--{option}'] - if meta.shortname is not None: - names.append(f'-{meta.shortname}') + short = entry.cli_short + if short is not None: + names.append(f'-{short}') return names @@ -54,8 +57,7 @@ class CLIManager: """CLI manager. Args: - conf_manager_: the :class:`~loam.manager.ConfigurationManager` holding - option definitions. + config_: the :class:`~loam.base.Config` holding option definitions. common_: special subcommand, used to define the general description of the CLI tool as well as configuration sections used by every subcommand. @@ -67,11 +69,11 @@ class CLIManager: this function. """ - def __init__(self, conf_manager_: ConfigurationManager, + def __init__(self, config_: Config, common_: Optional[Subcmd] = None, bare_: Optional[Subcmd] = None, **subcmds: Subcmd): - self._conf = conf_manager_ + self._conf = config_ self._subcmds = {} for sub_name, sub_meta in subcmds.items(): if sub_name.isidentifier(): @@ -122,7 +124,7 @@ def sections_list(self, cmd: Optional[str] = None) -> List[str]: return sections return [] sections.extend(self.subcmds[cmd].sections) - if cmd in self._conf: + if hasattr(self._conf, cmd): sections.append(cmd) return sections @@ -131,8 +133,10 @@ def _cmd_opts_solver(self, cmd_name: Optional[str]) -> None: sections = self.sections_list(cmd_name) cmd_dict = self._opt_cmds[cmd_name] if cmd_name else self._opt_bare for sct in reversed(sections): - for opt, opt_meta in self._conf[sct].def_.items(): - if not opt_meta.cmd_arg: + section: Section = getattr(self._conf, sct) + for fld in fields(section): + opt = fld.name + if not section.meta_(opt).entry.in_cli: continue if opt not in cmd_dict: cmd_dict[opt] = sct @@ -145,18 +149,16 @@ def _cmd_opts_solver(self, cmd_name: Optional[str]) -> None: def _add_options_to_parser(self, opts_dict: Mapping[str, str], parser: ArgumentParser) -> None: """Add options to a parser.""" - store_bool = ('store_true', 'store_false') for opt, sct in opts_dict.items(): - meta = self._conf[sct].def_[opt] - kwargs = copy.deepcopy(meta.cmd_kwargs) + section: Section = getattr(self._conf, sct) + entry = section.meta_(opt).entry + kwargs = copy.deepcopy(entry.cli_kwargs) action = kwargs.get('action') if action is _internal.Switch: kwargs.update(nargs=0) - elif meta.default is not None and action not in store_bool: - kwargs.setdefault('type', type(meta.default)) - kwargs.update(help=meta.help) - kwargs.setdefault('default', self._conf[sct][opt]) - parser.add_argument(*_names(self._conf[sct], opt), **kwargs) + kwargs.update(help=entry.doc) + kwargs.setdefault('default', getattr(section, opt)) + parser.add_argument(*_names(section, opt), **kwargs) def _build_parser(self) -> ArgumentParser: """Build command line argument parser. @@ -196,10 +198,20 @@ def parse_args(self, arglist: Optional[List[str]] = None) -> Namespace: sub_cmd = args.loam_sub_name if sub_cmd is None: for opt, sct in self._opt_bare.items(): - self._conf[sct][opt] = getattr(args, opt, None) + section: Section = getattr(self._conf, sct) + val = getattr(args, opt, None) + if isinstance(val, str): + section.set_from_str_(opt, val) + else: + setattr(section, opt, val) else: for opt, sct in self._opt_cmds[sub_cmd].items(): - self._conf[sct][opt] = getattr(args, opt, None) + section = getattr(self._conf, sct) + val = getattr(args, opt, None) + if isinstance(val, str): + section.set_from_str_(opt, val) + else: + setattr(section, opt, val) return args def _zsh_comp_command(self, zcf: TextIO, cmd: Optional[str], @@ -222,16 +234,17 @@ def _zsh_comp_command(self, zcf: TextIO, cmd: Optional[str], no_comp = ('store_true', 'store_false') cmd_dict = self._opt_cmds[cmd] if cmd else self._opt_bare for opt, sct in cmd_dict.items(): - meta = self._conf[sct].def_[opt] - comprule = meta.comprule - if meta.cmd_kwargs.get('action') == 'append': + section: Section = getattr(self._conf, sct) + entry = section.meta_(opt).entry + comprule = entry.cli_zsh_comprule + if entry.cli_kwargs.get('action') == 'append': grpfmt, optfmt = "+ '{}'", "'*{}[{}]{}'" if comprule is None: comprule = '' else: grpfmt, optfmt = "+ '({})'", "'{}[{}]{}'" - if meta.cmd_kwargs.get('action') in no_comp \ - or meta.cmd_kwargs.get('nargs') == 0: + if entry.cli_kwargs.get('action') in no_comp \ + or entry.cli_kwargs.get('nargs') == 0: comprule = None if comprule is None: compstr = '' @@ -243,8 +256,8 @@ def _zsh_comp_command(self, zcf: TextIO, cmd: Optional[str], compstr = f': :{comprule}' if grouping: print(grpfmt.format(opt), end=BLK, file=zcf) - for name in _names(self._conf[sct], opt): - print(optfmt.format(name, meta.help.replace("'", "'\"'\"'"), + for name in _names(section, opt): + print(optfmt.format(name, entry.doc.replace("'", "'\"'\"'"), compstr), end=BLK, file=zcf) def zsh_complete(self, path: Union[str, PathLike], cmd: str, *cmds: str, @@ -310,7 +323,8 @@ def _bash_comp_command(self, cmd: Optional[str], out = ['-h', '--help'] if add_help else [] cmd_dict = self._opt_cmds[cmd] if cmd else self._opt_bare for opt, sct in cmd_dict.items(): - out.extend(_names(self._conf[sct], opt)) + section: Section = getattr(self._conf, sct) + out.extend(_names(section, opt)) return out def bash_complete(self, path: Union[str, PathLike], cmd: str, diff --git a/loam/manager.py b/loam/manager.py deleted file mode 100644 index d318a8b..0000000 --- a/loam/manager.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Definition of configuration manager classes. - -Note: - All methods and attributes are postfixed with an underscore to minimize the - risk of collision with the names of your configuration sections and - options. -""" - -from __future__ import annotations -from dataclasses import dataclass, field -from pathlib import Path -from types import MappingProxyType -import typing - -import toml - -from . import error - -if typing.TYPE_CHECKING: - from typing import (Dict, List, Any, Union, Tuple, Mapping, Optional, - Iterator, Iterable) - from os import PathLike - - -def _is_valid(name: str) -> bool: - """Check if a section or option name is valid.""" - return name.isidentifier() and name != 'loam_sub_name' - - -@dataclass(frozen=True) -class ConfOpt: - """Metadata of configuration options. - - Attributes: - default: the default value of the configuration option. - cmd_arg: whether the option is a command line argument. - shortname: short version of the command line argument. - cmd_kwargs: keyword arguments fed to - :meth:`argparse.ArgumentParser.add_argument` during the - construction of the command line arguments parser. - conf_arg: whether the option can be set in the config file. - help: short description of the option. - comprule: completion rule for ZSH shell. - """ - - default: Any - cmd_arg: bool = False - shortname: Optional[str] = None - cmd_kwargs: Dict[str, Any] = field(default_factory=dict) - conf_arg: bool = False - help: str = '' - comprule: Optional[str] = '' - - -class Section: - """Hold options for a single section. - - Args: - options: option metadata. The name of each *option* is the name of the - keyword argument passed on to this function. Option names should be - valid identifiers, otherwise an :class:`~loam.error.OptionError` is - raised. - """ - - def __init__(self, **options: ConfOpt): - super().__setattr__("_def", {}) - for opt_name, opt_meta in options.items(): - if _is_valid(opt_name): - self._def[opt_name] = opt_meta - self[opt_name] = opt_meta.default - else: - raise error.OptionError(opt_name) - - @property - def def_(self) -> Mapping[str, Any]: - """Return mapping of default values of options.""" - return MappingProxyType(self._def) - - def __getitem__(self, opt: str) -> Any: - return getattr(self, opt) - - def __setitem__(self, opt: str, value: Any) -> None: - setattr(self, opt, value) - - def __delitem__(self, opt: str) -> None: - delattr(self, opt) - - def __delattr__(self, opt: str) -> None: - if opt not in self: - raise error.OptionError(opt) - self[opt] = self.def_[opt].default - - def __getattr__(self, opt: str) -> Any: - raise error.OptionError(opt) - - def __setattr__(self, opt: str, val: Any) -> None: - if opt in self._def: - super().__setattr__(opt, val) - else: - raise error.OptionError(opt) - - def __iter__(self) -> Iterator[str]: - return iter(self.def_.keys()) - - def __contains__(self, opt: str) -> bool: - return opt in self.def_ - - def options_(self) -> Iterator[str]: - """Iterate over configuration option names.""" - return iter(self) - - def opt_vals_(self) -> Iterator[Tuple[str, Any]]: - """Iterate over option names and option values.""" - for opt in self.options_(): - yield opt, self[opt] - - def defaults_(self) -> Iterable[Tuple[str, ConfOpt]]: - """Iterate over option names, and option metadata.""" - return self.def_.items() - - def update_(self, sct_dict: Mapping[str, Any], - conf_arg: bool = True) -> None: - """Update values of configuration section with dict. - - Args: - sct_dict: mapping of option names to their new values. Unknown - options are discarded. - conf_arg: if True, only options that can be set in a config file - are updated. - """ - for opt, val in sct_dict.items(): - if opt not in self.def_: - continue - if not conf_arg or self.def_[opt].conf_arg: - self[opt] = val - - def reset_(self) -> None: - """Restore default values of options in this section.""" - for opt, meta in self.defaults_(): - self[opt] = meta.default - - -class ConfigurationManager: - """Configuration manager. - - Configuration options are organized in sections. A configuration option can - be accessed both with attribute and item access notations, these two lines - access the same option value:: - - conf.some_section.some_option - conf['some_section']['some_option'] - - To reset a configuration option (or an entire section) to its default - value, simply delete it (with item or attribute notation):: - - del conf['some_section'] # reset all options in 'some_section' - del conf.some_section.some_option # reset a particular option - - It will be set to its default value the next time you access it. - - Args: - sections: section metadata. The name of each *section* is the name of - the keyword argument passed on to this function. Section names - should be valid identifiers, otherwise a - :class:`~loam.error.SectionError` is raised. - """ - - def __init__(self, **sections: Section): - self._sections = [] - for sct_name, sct_meta in sections.items(): - if _is_valid(sct_name): - setattr(self, sct_name, Section(**sct_meta.def_)) - self._sections.append(sct_name) - else: - raise error.SectionError(sct_name) - self._parser = None - self._nosub_valid = False - self._config_files: Tuple[Path, ...] = () - - @classmethod - def from_dict_( - cls, conf_dict: Mapping[str, Mapping[str, ConfOpt]] - ) -> ConfigurationManager: - """Use a dictionary to create a :class:`ConfigurationManager`. - - Args: - conf_dict: the first level of keys are section names. The second - level are option names. The values are the options metadata. - - Returns: - a configuration manager with the requested sections and options. - """ - return cls(**{name: Section(**opts) - for name, opts in conf_dict.items()}) - - @property - def config_files_(self) -> Tuple[Path, ...]: - """Path of config files. - - The config files are in the order of reading. This means the most - global config file is the first one on this list while the most local - config file is the last one. - """ - return self._config_files - - def set_config_files_(self, *config_files: Union[str, PathLike]) -> None: - """Set the list of config files. - - Args: - config_files: path of config files, given in the order - of reading. - """ - self._config_files = tuple(Path(path) for path in config_files) - - def __getitem__(self, sct: str) -> Section: - return getattr(self, sct) - - def __delitem__(self, sct: str) -> None: - delattr(self, sct) - - def __delattr__(self, sct: str) -> None: - self[sct].reset_() - - def __getattr__(self, sct: str) -> Section: - raise error.SectionError(sct) - - def __iter__(self) -> Iterator[str]: - return iter(self._sections) - - def __contains__(self, sct: str) -> bool: - return sct in self._sections - - def sections_(self) -> Iterator[str]: - """Iterate over configuration section names.""" - return iter(self) - - def options_(self) -> Iterator[Tuple[str, str]]: - """Iterate over section and option names. - - This iterator is also implemented at the section level. The two loops - produce the same output:: - - for sct, opt in conf.options_(): - print(sct, opt) - - for sct in conf.sections_(): - for opt in conf[sct].options_(): - print(sct, opt) - """ - for sct in self: - for opt in self[sct]: - yield sct, opt - - def opt_vals_(self) -> Iterator[Tuple[str, str, Any]]: - """Iterate over sections, option names, and option values. - - This iterator is also implemented at the section level. The two loops - produce the same output:: - - for sct, opt, val in conf.opt_vals_(): - print(sct, opt, val) - - for sct in conf.sections_(): - for opt, val in conf[sct].opt_vals_(): - print(sct, opt, val) - """ - for sct, opt in self.options_(): - yield sct, opt, self[sct][opt] - - def defaults_(self) -> Iterator[Tuple[str, str, Any]]: - """Iterate over sections, option names, and option metadata. - - This iterator is also implemented at the section level. The two loops - produce the same output:: - - for sct, opt, meta in conf.defaults_(): - print(sct, opt, meta.default) - - for sct in conf.sections_(): - for opt, meta in conf[sct].defaults_(): - print(sct, opt, meta.default) - """ - for sct, opt in self.options_(): - yield sct, opt, self[sct].def_[opt] - - def reset_(self) -> None: - """Restore default values of all options.""" - for sct, opt, meta in self.defaults_(): - self[sct][opt] = meta.default - - def create_config_(self, index: int = 0, update: bool = False) -> None: - """Create config file. - - Create config file in :attr:`config_files_[index]`. - - Parameters: - index: index of config file. - update: if set to True and :attr:`config_files_` already exists, - its content is read and all the options it sets are kept in the - produced config file. - """ - if not self.config_files_[index:]: - return - path = self.config_files_[index] - if not path.parent.exists(): - path.parent.mkdir(parents=True) - conf_dict: Dict[str, Dict[str, Any]] = {} - for section in self.sections_(): - conf_opts = [o for o, m in self[section].defaults_() if m.conf_arg] - if not conf_opts: - continue - conf_dict[section] = {} - for opt in conf_opts: - conf_dict[section][opt] = (self[section][opt] if update else - self[section].def_[opt].default) - with path.open('w') as cfile: - toml.dump(conf_dict, cfile) - - def update_(self, conf_dict: Mapping[str, Mapping[str, Any]], - conf_arg: bool = True) -> None: - """Update values of configuration options with dict. - - Args: - conf_dict: new values indexed by section and option names. - conf_arg: if True, only options that can be set in a config file - are updated. - """ - for section, secdict in conf_dict.items(): - self[section].update_(secdict, conf_arg) - - def read_config_(self, cfile: Path) -> Optional[Mapping]: - """Read a config file and set config values accordingly. - - Returns: - content of config file. - """ - if not cfile.exists(): - return {} - try: - conf_dict = toml.load(str(cfile)) - except toml.TomlDecodeError: - return None - self.update_(conf_dict) - return conf_dict - - def read_configs_(self) -> Tuple[Dict[str, Dict[str, Any]], List[Path], - List[Path]]: - """Read config files and set config values accordingly. - - Returns: - respectively content of files, list of missing/empty files and list - of files for which a parsing error arised. - """ - if not self.config_files_: - return {}, [], [] - content: Dict[str, Dict[str, Any]] = {section: {} for section in self} - empty_files = [] - faulty_files = [] - for cfile in self.config_files_: - conf_dict = self.read_config_(cfile) - if conf_dict is None: - faulty_files.append(cfile) - continue - elif not conf_dict: - empty_files.append(cfile) - continue - for section, secdict in conf_dict.items(): - content[section].update(secdict) - return content, empty_files, faulty_files diff --git a/loam/tools.py b/loam/tools.py index b319090..193be0d 100644 --- a/loam/tools.py +++ b/loam/tools.py @@ -9,7 +9,6 @@ from . import _internal from .base import Entry, Section, Config -from .manager import ConfOpt if typing.TYPE_CHECKING: from pathlib import Path @@ -19,8 +18,8 @@ def switch_opt(default: bool, shortname: Optional[str], - help_msg: str) -> ConfOpt: - """Define a switchable ConfOpt. + doc: str) -> bool: + """Define a switchable option. This creates a boolean option. If you use it in your CLI, it can be switched on and off by prepending + or - to its name: +opt / -opt. @@ -29,13 +28,12 @@ def switch_opt(default: bool, shortname: Optional[str], default: the default value of the swith option. shortname: short name of the option, no shortname will be used if set to None. - help_msg: short description of the option. - - Returns: - a :class:`~loam.manager.ConfOpt` with the relevant properties. + doc: short description of the option. """ - return ConfOpt(bool(default), True, shortname, - dict(action=_internal.Switch), True, help_msg, None) + return Entry( + val=default, doc=doc, cli_short=shortname, + cli_kwargs=dict(action=_internal.Switch), cli_zsh_comprule=None + ).field() def command_flag(doc: str, shortname: Optional[str] = None) -> bool: diff --git a/tests/conftest.py b/tests/conftest.py index 8e59ddb..01972f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,35 +3,36 @@ import pytest -from loam.manager import ConfOpt, Section, ConfigurationManager from loam.cli import Subcmd, CLIManager from loam.tools import switch_opt -import loam.base - - -@pytest.fixture(scope='session', params=['confA']) -def conf_def(request): - metas = {} - metas['confA'] = { - 'sectionA': Section( - optA=ConfOpt(1, True, 'a', {}, True, 'AA'), - optB=ConfOpt(2, True, None, {}, False, 'AB'), - optC=ConfOpt(3, True, None, {}, True, 'AC'), - optBool=switch_opt(True, 'o', 'Abool'), - ), - 'sectionB': Section( - optA=ConfOpt(4, True, None, {}, True, 'BA'), - optB=ConfOpt(5, True, None, {}, False, 'BB'), - optC=ConfOpt(6, False, None, {}, True, 'BC'), - optBool=switch_opt(False, 'o', 'Bbool'), - ), - } - return metas[request.param] +from loam.base import Entry, Section, Config + + +@dataclass +class SecA(Section): + optA: int = Entry(val=1, doc="AA", cli_short='a').field() + optB: int = Entry(val=2, doc="AB", in_file=False).field() + optC: int = Entry(val=3, doc="AC").field() + optBool: bool = switch_opt(True, 'o', 'Abool') + + +@dataclass +class SecB(Section): + optA: int = Entry(val=4, doc="BA").field() + optB: int = Entry(val=5, doc="BB", in_file=False).field() + optC: int = Entry(val=6, doc="BC", in_cli=False).field() + optBool: int = switch_opt(False, 'o', 'Bbool') + + +@dataclass +class Conf(Config): + sectionA: SecA + sectionB: SecB @pytest.fixture -def conf(conf_def): - return ConfigurationManager(**conf_def) +def conf() -> Conf: + return Conf.default_() @pytest.fixture(params=['subsA']) @@ -55,20 +56,8 @@ def cfile(tmp_path): return tmp_path / 'config.toml' -@pytest.fixture -def nonexistent_file(tmp_path): - return tmp_path / 'dummy.toml' - - -@pytest.fixture -def illtoml(tmp_path): - path = tmp_path / 'ill.toml' - path.write_text('not}valid[toml\n') - return path - - @dataclass -class SectionA(loam.base.Section): +class SectionA(Section): some_n: int = 42 some_str: str = "foo" @@ -79,9 +68,9 @@ def section_a() -> SectionA: @dataclass -class SectionB(loam.base.Section): - some_path: Path = loam.base.Entry(val=Path(), to_str=str).field() - some_str: str = loam.base.Entry(val="bar", in_file=False).field() +class SectionB(Section): + some_path: Path = Entry(val=Path(), to_str=str).field() + some_str: str = Entry(val="bar", in_file=False).field() @pytest.fixture @@ -90,7 +79,7 @@ def section_b() -> SectionB: @dataclass -class MyConfig(loam.base.Config): +class MyConfig(Config): section_a: SectionA section_b: SectionB diff --git a/tests/test_base.py b/tests/test_base.py index 288a1e3..4fa4d5c 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -145,3 +145,14 @@ class MyConfig(Config): dummy: int = 5 with pytest.raises(TypeError): MyConfig.default_() + + +def test_update_opt(conf): + conf.sectionA.update_from_dict_({'optA': 42, 'optC': 43}) + assert conf.sectionA.optA == 42 and conf.sectionA.optC == 43 + + +def test_update_section(conf): + conf.update_from_dict_( + {'sectionA': {'optA': 42}, 'sectionB': {'optA': 43}}) + assert conf.sectionA.optA == 42 and conf.sectionB.optA == 43 diff --git a/tests/test_config_file.py b/tests/test_config_file.py index 4ac079d..05d8bbd 100644 --- a/tests/test_config_file.py +++ b/tests/test_config_file.py @@ -1,79 +1,24 @@ import toml -def test_config_files_setter(conf, cfile): - assert conf.config_files_ == () - conf.set_config_files_(str(cfile)) - assert conf.config_files_ == (cfile,) - - def test_create_config(conf, cfile): - conf.set_config_files_(cfile) - conf.create_config_() - conf_dict = toml.load(str(cfile)) - assert conf_dict == {'sectionA': {'optA': 1, 'optC': 3, 'optBool': True}, - 'sectionB': {'optA': 4, 'optC': 6, 'optBool': False}} - - -def test_create_config_index(conf, cfile, nonexistent_file): - conf.set_config_files_(nonexistent_file, cfile) - conf.create_config_(index=1) + conf.to_file_(cfile) conf_dict = toml.load(str(cfile)) assert conf_dict == {'sectionA': {'optA': 1, 'optC': 3, 'optBool': True}, 'sectionB': {'optA': 4, 'optC': 6, 'optBool': False}} def test_create_config_no_update(conf, cfile): - conf.set_config_files_(cfile) conf.sectionA.optA = 42 - conf.create_config_() + conf.default_().to_file_(cfile) conf_dict = toml.load(str(cfile)) assert conf_dict == {'sectionA': {'optA': 1, 'optC': 3, 'optBool': True}, 'sectionB': {'optA': 4, 'optC': 6, 'optBool': False}} def test_create_config_update(conf, cfile): - conf.set_config_files_(cfile) conf.sectionA.optA = 42 - conf.create_config_(update=True) + conf.to_file_(cfile) conf_dict = toml.load(str(cfile)) assert conf_dict == {'sectionA': {'optA': 42, 'optC': 3, 'optBool': True}, 'sectionB': {'optA': 4, 'optC': 6, 'optBool': False}} - - -def test_read_config(conf, cfile): - conf.set_config_files_(cfile) - conf.create_config_() - conf_dict = conf.read_config_(cfile) - assert conf_dict == {'sectionA': {'optA': 1, 'optC': 3, 'optBool': True}, - 'sectionB': {'optA': 4, 'optC': 6, 'optBool': False}} - - -def test_read_config_missing(conf, cfile): - assert conf.read_config_(cfile) == {} - - -def test_read_config_empty(conf, cfile): - cfile.touch() - assert conf.read_config_(cfile) == {} - - -def test_read_config_invalid(conf, illtoml): - assert conf.read_config_(illtoml) is None - - -def test_read_configs(conf, cfile): - conf.set_config_files_(cfile) - conf.create_config_() - conf_dict, empty, faulty = conf.read_configs_() - assert empty == faulty == [] - assert conf_dict == {'sectionA': {'optA': 1, 'optC': 3, 'optBool': True}, - 'sectionB': {'optA': 4, 'optC': 6, 'optBool': False}} - - -def test_read_configs_missing_invalid(conf, nonexistent_file, illtoml): - conf.set_config_files_(illtoml, nonexistent_file) - conf_dict, empty, faulty = conf.read_configs_() - assert empty == [nonexistent_file] - assert faulty == [illtoml] - assert conf_dict == {'sectionA': {}, 'sectionB': {}} diff --git a/tests/test_config_opts.py b/tests/test_config_opts.py deleted file mode 100644 index cf8c938..0000000 --- a/tests/test_config_opts.py +++ /dev/null @@ -1,172 +0,0 @@ -import pytest -import loam.error -import loam.manager - - -def test_build_from_dict(conf, conf_def): - conf_dict = {name: dict(sct.def_) for name, sct in conf_def.items()} - conf_fd = loam.manager.ConfigurationManager.from_dict_(conf_dict) - assert all(conf[s][o] == conf_fd[s][o] - for s in conf_def for o in conf_def[s]) - - -def test_build_section_invalid_option_name(): - invalid_id = 'not a valid id' - with pytest.raises(loam.error.OptionError) as err: - loam.manager.Section(**{invalid_id: 'dummy'}) - assert err.value.option == invalid_id - - -def test_build_manager_invalid_section_name(): - invalid_id = 'not a valid id' - with pytest.raises(loam.error.SectionError) as err: - loam.manager.ConfigurationManager(**{invalid_id: 'dummy'}) - assert err.value.section == invalid_id - - -def test_get_subconfig(conf, conf_def): - for sub in conf_def: - assert getattr(conf, sub) is conf[sub] - - -def test_get_opt(conf): - for sub, opt in conf.options_(): - assert getattr(conf[sub], opt) is conf[sub][opt] - - -def test_set_opt_def(conf): - with pytest.raises(TypeError): - conf.sectionA.def_['optA'] = None - - -def test_get_invalid_subconfig(conf): - invalid = 'invalidsubdummy' - with pytest.raises(loam.error.SectionError) as err: - _ = conf[invalid] - assert err.value.section == invalid - - -def test_get_invalid_opt(conf): - invalid = 'invalidoptdummy' - for sub in conf: - with pytest.raises(loam.error.OptionError) as err: - _ = conf[sub][invalid] - assert err.value.option == invalid - - -def test_contains_section(conf, conf_def): - for sub in conf_def: - assert sub in conf - - -def test_contains_option(conf, conf_def): - for sub, opts in conf_def.items(): - for opt in opts: - assert opt in conf[sub] - - -def test_contains_invalid_section(conf): - assert 'invalidsubdummy' not in conf - - -def test_contains_invalid_option(conf): - assert 'invalidoptdummy' not in conf.sectionA - - -def test_reset_all(conf): - conf.sectionA.optA = 42 - conf.reset_() - assert conf.sectionA.optA == 1 - - -def test_reset_subconfig(conf): - conf.sectionA.optA = 42 - del conf.sectionA - assert conf.sectionA.optA == 1 - - -def test_reset_subconfig_item(conf): - conf.sectionA.optA = 42 - del conf['sectionA'] - assert conf.sectionA.optA == 1 - - -def test_reset_subconfig_func(conf): - conf.sectionA.optA = 42 - conf.sectionA.reset_() - assert conf.sectionA.optA == 1 - - -def test_reset_opt(conf): - conf.sectionA.optA = 42 - del conf.sectionA.optA - assert conf.sectionA.optA == 1 - - -def test_reset_opt_item(conf): - conf.sectionA.optA = 42 - del conf.sectionA['optA'] - assert conf.sectionA.optA == 1 - - -def test_update_opt(conf): - conf.sectionA.update_({'optA': 42, 'optC': 43}) - assert conf.sectionA.optA == 42 and conf.sectionA.optC == 43 - - -def test_update_section(conf): - conf.update_({'sectionA': {'optA': 42}, 'sectionB': {'optA': 43}}) - assert conf.sectionA.optA == 42 and conf.sectionB.optA == 43 - - -def test_update_opt_conf_arg(conf): - conf.sectionA.update_({'optB': 42}) - assert conf.sectionA.optB == 2 - conf.sectionA.update_({'optB': 42}, conf_arg=False) - assert conf.sectionA.optB == 42 - - -def test_update_section_conf_arg(conf): - conf.update_({'sectionA': {'optB': 42}, 'sectionB': {'optB': 43}}) - assert conf.sectionA.optB == 2 and conf.sectionB.optB == 5 - conf.update_({'sectionA': {'optB': 42}, 'sectionB': {'optB': 43}}, - conf_arg=False) - assert conf.sectionA.optB == 42 and conf.sectionB.optB == 43 - - -def test_opt_def_values(conf, conf_def): - assert all(conf[s].def_[o] == conf_def[s].def_[o] - for s in conf_def for o in conf_def[s]) - - -def test_config_iter_subs(conf, conf_def): - raw_iter = set(iter(conf)) - scts_iter = set(conf.sections_()) - scts_expected = set(conf_def.keys()) - assert raw_iter == scts_iter == scts_expected - - -def test_config_iter_options(conf, conf_def): - options_iter = set(conf.options_()) - options_expected = set((sub, opt) for sub in conf_def - for opt in conf_def[sub]) - assert options_iter == options_expected - - -def test_config_iter_default_val(conf): - vals_iter = set(conf.opt_vals_()) - vals_dflts = set((s, o, m.default) for s, o, m in conf.defaults_()) - assert vals_iter == vals_dflts - - -def test_config_iter_subconfig(conf, conf_def): - raw_iter = set(iter(conf.sectionA)) - opts_iter = set(conf.sectionA.options_()) - opts_expected = set(conf_def['sectionA']) - assert raw_iter == opts_iter == opts_expected - - -def test_config_iter_subconfig_default_val(conf): - vals_iter = set(conf.sectionA.opt_vals_()) - vals_dflts = set((o, m.default) for o, m in conf.sectionA.defaults_()) - assert vals_iter == vals_dflts diff --git a/tests/test_parser.py b/tests/test_parser.py index db87646..4ae6d38 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -6,8 +6,7 @@ def test_parse_no_args(conf, climan): climan.parse_args([]) - for s, o, m in conf.defaults_(): - assert conf[s][o] == m.default + assert conf == conf.default_() def test_parse_nosub_common_args(conf, climan): From 1deb01eb1692c82908c33e9d756dfa60050590da Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Tue, 26 Apr 2022 03:21:05 +0100 Subject: [PATCH 25/39] New base.entry helper function --- loam/base.py | 31 +++++++++++++++++++++++++++++++ tests/conftest.py | 18 +++++++++--------- tests/test_base.py | 16 +++++++--------- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/loam/base.py b/loam/base.py index 7c034b3..df29d96 100644 --- a/loam/base.py +++ b/loam/base.py @@ -76,6 +76,37 @@ def func() -> T: return field(default_factory=func, metadata=dict(loam_entry=self)) +def entry( + val: Optional[T] = None, + val_str: Optional[str] = None, + val_factory: Optional[Callable[[], T]] = None, + doc: str = "", + from_str: Optional[Callable[[str], T]] = None, + to_str: Optional[Callable[[T], str]] = None, + in_file: bool = True, + in_cli: bool = True, + cli_short: Optional[str] = None, + cli_kwargs: Dict[str, Any] = None, + cli_zsh_comprule: Optional[str] = '', +) -> T: + """Build Entry(...).field().""" + if cli_kwargs is None: + cli_kwargs = {} + return Entry( + val=val, + val_str=val_str, + val_factory=val_factory, + doc=doc, + from_str=from_str, + to_str=to_str, + in_file=in_file, + in_cli=in_cli, + cli_short=cli_short, + cli_kwargs=cli_kwargs, + cli_zsh_comprule=cli_zsh_comprule, + ).field() + + @dataclass(frozen=True) class Meta(Generic[T]): """Group several metadata of configuration entry. diff --git a/tests/conftest.py b/tests/conftest.py index 01972f3..0367604 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,22 +5,22 @@ from loam.cli import Subcmd, CLIManager from loam.tools import switch_opt -from loam.base import Entry, Section, Config +from loam.base import entry, Section, Config @dataclass class SecA(Section): - optA: int = Entry(val=1, doc="AA", cli_short='a').field() - optB: int = Entry(val=2, doc="AB", in_file=False).field() - optC: int = Entry(val=3, doc="AC").field() + optA: int = entry(val=1, doc="AA", cli_short='a') + optB: int = entry(val=2, doc="AB", in_file=False) + optC: int = entry(val=3, doc="AC") optBool: bool = switch_opt(True, 'o', 'Abool') @dataclass class SecB(Section): - optA: int = Entry(val=4, doc="BA").field() - optB: int = Entry(val=5, doc="BB", in_file=False).field() - optC: int = Entry(val=6, doc="BC", in_cli=False).field() + optA: int = entry(val=4, doc="BA") + optB: int = entry(val=5, doc="BB", in_file=False) + optC: int = entry(val=6, doc="BC", in_cli=False) optBool: int = switch_opt(False, 'o', 'Bbool') @@ -69,8 +69,8 @@ def section_a() -> SectionA: @dataclass class SectionB(Section): - some_path: Path = Entry(val=Path(), to_str=str).field() - some_str: str = Entry(val="bar", in_file=False).field() + some_path: Path = entry(val=Path(), to_str=str) + some_str: str = entry(val="bar", in_file=False) @pytest.fixture diff --git a/tests/test_base.py b/tests/test_base.py index 4fa4d5c..c69e1be 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -6,7 +6,7 @@ import pytest import toml -from loam.base import Entry, Section, Config +from loam.base import entry, Section, Config class MyMut: @@ -23,7 +23,7 @@ def from_str(s: str) -> MyMut: def test_with_val(): @dataclass class MySection(Section): - some_n: int = Entry(val=42).field() + some_n: int = entry(val=42) sec = MySection() assert sec.some_n == 42 @@ -31,7 +31,7 @@ class MySection(Section): def test_two_vals_fail(): with pytest.raises(ValueError): - Entry(val=5, val_factory=lambda: 5).field() + entry(val=5, val_factory=lambda: 5) def test_set_from_str_type_hint(section_a): @@ -60,8 +60,7 @@ def test_context_from_str(section_b): def test_with_str_mutable_protected(): @dataclass class MySection(Section): - some_mut: MyMut = Entry( - val_str="4.5,3.8", from_str=MyMut.from_str).field() + some_mut: MyMut = entry(val_str="4.5,3.8", from_str=MyMut.from_str) MySection().some_mut.inner_list.append(5.6) assert MySection().some_mut.inner_list == [4.5, 3.8] @@ -70,15 +69,14 @@ class MySection(Section): def test_type_hint_not_a_class(): @dataclass class MySection(Section): - maybe_n: Optional[int] = Entry( - val_factory=lambda: None, from_str=int).field() + maybe_n: Optional[int] = entry(val_factory=lambda: None, from_str=int) assert MySection().maybe_n is None assert MySection("42").maybe_n == 42 def test_with_str_no_from_str(): with pytest.raises(ValueError): - Entry(val_str="5").field() + entry(val_str="5") def test_init_wrong_type(): @@ -92,7 +90,7 @@ class MySection(Section): def test_missing_from_str(): @dataclass class MySection(Section): - my_mut: MyMut = Entry(val_factory=lambda: MyMut([4.5])).field() + my_mut: MyMut = entry(val_factory=lambda: MyMut([4.5])) sec = MySection() assert sec.my_mut.inner_list == [4.5] with pytest.raises(ValueError): From 785edcc38eb0572b0c9792b4db623e3c7a1dbffc Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Tue, 26 Apr 2022 03:26:51 +0100 Subject: [PATCH 26/39] Rename test files --- tests/{test_parser.py => test_cli.py} | 0 tests/{test_types.py => test_parsers.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/{test_parser.py => test_cli.py} (100%) rename tests/{test_types.py => test_parsers.py} (100%) diff --git a/tests/test_parser.py b/tests/test_cli.py similarity index 100% rename from tests/test_parser.py rename to tests/test_cli.py diff --git a/tests/test_types.py b/tests/test_parsers.py similarity index 100% rename from tests/test_types.py rename to tests/test_parsers.py From 5c80ffab291bb38fcc114a4fd443d4196f09c324 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Tue, 26 Apr 2022 16:03:50 +0100 Subject: [PATCH 27/39] New Section.set_safe_ method This simplify and make other methods more robust against type error at runtime. --- loam/base.py | 36 ++++++++++++++++++++++++------------ loam/cli.py | 10 ++-------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/loam/base.py b/loam/base.py index df29d96..ad8f07a 100644 --- a/loam/base.py +++ b/loam/base.py @@ -144,20 +144,35 @@ def __post_init__(self) -> None: if not isinstance(thint, type): thint = object self._loam_meta[fld.name] = Meta(fld, meta, thint) - current_val = getattr(self, fld.name) - if (not issubclass(thint, str)) and isinstance(current_val, str): - self.set_from_str_(fld.name, current_val) - current_val = getattr(self, fld.name) - if not isinstance(current_val, thint): - typ = type(current_val) - raise TypeError( - f"Expected a {thint} for {fld.name}, received a {typ}.") + if isinstance(current_val, str) or not isinstance(current_val, + thint): + self.set_safe_(fld.name, current_val) def meta_(self, entry_name: str) -> Meta: """Metadata for the given entry name.""" return self._loam_meta[entry_name] + def set_safe_(self, entry_name: str, value: Any) -> None: + """Set an option from a value or a string. + + This method is only meant as a convenience to manipulate + :class:`Section` instances in a dynamic way. It parses strings if + necessary and raises `TypeError` when the type can be determined to be + incorrect. When possible, either prefer directly setting the attribute + or calling :meth:`set_from_str_` as those can be statically checked. + """ + if isinstance(value, str): + self.set_from_str_(entry_name, value) + else: + typ = self.meta_(entry_name).type_hint + if isinstance(value, typ): + setattr(self, entry_name, value) + else: + typg = type(value) + raise TypeError( + f"Expected a {typ} for {entry_name}, received a {typg}.") + def set_from_str_(self, field_name: str, value_as_str: str) -> None: """Set an option from the string representation of the value. @@ -187,10 +202,7 @@ def context_(self, **options: Any) -> ContextManager[None]: def update_from_dict_(self, options: Mapping[str, Any]) -> None: """Update options from a mapping, parsing str as needed.""" for opt, val in options.items(): - if isinstance(val, str): - self.set_from_str_(opt, val) - else: - setattr(self, opt, val) + self.set_safe_(opt, val) TConfig = TypeVar("TConfig", bound="Config") diff --git a/loam/cli.py b/loam/cli.py index d7bc6f0..08b9183 100644 --- a/loam/cli.py +++ b/loam/cli.py @@ -200,18 +200,12 @@ def parse_args(self, arglist: Optional[List[str]] = None) -> Namespace: for opt, sct in self._opt_bare.items(): section: Section = getattr(self._conf, sct) val = getattr(args, opt, None) - if isinstance(val, str): - section.set_from_str_(opt, val) - else: - setattr(section, opt, val) + section.set_safe_(opt, val) else: for opt, sct in self._opt_cmds[sub_cmd].items(): section = getattr(self._conf, sct) val = getattr(args, opt, None) - if isinstance(val, str): - section.set_from_str_(opt, val) - else: - setattr(section, opt, val) + section.set_safe_(opt, val) return args def _zsh_comp_command(self, zcf: TextIO, cmd: Optional[str], From 5f6fc78e8fa4e90b0584c4031341484444462409 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Tue, 26 Apr 2022 16:50:45 +0100 Subject: [PATCH 28/39] Rename Config to ConfigBase This allow calling code to define a Config class without name conflict. --- loam/base.py | 4 ++-- loam/cli.py | 6 +++--- loam/tools.py | 7 ++++--- tests/conftest.py | 6 +++--- tests/test_base.py | 4 ++-- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/loam/base.py b/loam/base.py index ad8f07a..cd41a9a 100644 --- a/loam/base.py +++ b/loam/base.py @@ -205,11 +205,11 @@ def update_from_dict_(self, options: Mapping[str, Any]) -> None: self.set_safe_(opt, val) -TConfig = TypeVar("TConfig", bound="Config") +TConfig = TypeVar("TConfig", bound="ConfigBase") @dataclass -class Config: +class ConfigBase: """Base class for a full configuration.""" @classmethod diff --git a/loam/cli.py b/loam/cli.py index 08b9183..c7ba932 100644 --- a/loam/cli.py +++ b/loam/cli.py @@ -14,7 +14,7 @@ from typing import Dict, List, Any, Optional, Mapping, TextIO, Union from argparse import ArgumentParser, Namespace from os import PathLike - from .base import Section, Config + from .base import Section, ConfigBase BLK = ' \\\n' # cutting line in scripts @@ -57,7 +57,7 @@ class CLIManager: """CLI manager. Args: - config_: the :class:`~loam.base.Config` holding option definitions. + config_: the :class:`~loam.base.ConfigBase` holding option definitions. common_: special subcommand, used to define the general description of the CLI tool as well as configuration sections used by every subcommand. @@ -69,7 +69,7 @@ class CLIManager: this function. """ - def __init__(self, config_: Config, + def __init__(self, config_: ConfigBase, common_: Optional[Subcmd] = None, bare_: Optional[Subcmd] = None, **subcmds: Subcmd): diff --git a/loam/tools.py b/loam/tools.py index 193be0d..3317e4e 100644 --- a/loam/tools.py +++ b/loam/tools.py @@ -8,12 +8,13 @@ import typing from . import _internal -from .base import Entry, Section, Config +from .base import Entry, Section if typing.TYPE_CHECKING: from pathlib import Path from typing import Optional, Union, Type from os import PathLike + from .base import ConfigBase from .cli import CLIManager @@ -66,14 +67,14 @@ class ConfigSection(Section): def config_cmd_handler( - config: Union[Config, Type[Config]], + config: Union[ConfigBase, Type[ConfigBase]], config_section: ConfigSection, config_file: Path, ) -> None: """Implement the behavior of a subcmd using config_conf_section. Args: - config: the :class:`~loam.base.Config` to manage. + config: the :class:`~loam.base.ConfigBase` to manage. config_section: a :class:`ConfigSection` set as desired. config_file: path to the config file. """ diff --git a/tests/conftest.py b/tests/conftest.py index 0367604..b3dfa71 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ from loam.cli import Subcmd, CLIManager from loam.tools import switch_opt -from loam.base import entry, Section, Config +from loam.base import entry, Section, ConfigBase @dataclass @@ -25,7 +25,7 @@ class SecB(Section): @dataclass -class Conf(Config): +class Conf(ConfigBase): sectionA: SecA sectionB: SecB @@ -79,7 +79,7 @@ def section_b() -> SectionB: @dataclass -class MyConfig(Config): +class MyConfig(ConfigBase): section_a: SectionA section_b: SectionB diff --git a/tests/test_base.py b/tests/test_base.py index c69e1be..f922bed 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -6,7 +6,7 @@ import pytest import toml -from loam.base import entry, Section, Config +from loam.base import entry, Section, ConfigBase class MyMut: @@ -139,7 +139,7 @@ def test_to_file_exist_ok(my_config, tmp_path): def test_config_with_not_section(): @dataclass - class MyConfig(Config): + class MyConfig(ConfigBase): dummy: int = 5 with pytest.raises(TypeError): MyConfig.default_() From 498f078f47167bb6f56fbbb5ba70911f9aa0f6a1 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Wed, 27 Apr 2022 20:42:57 +0100 Subject: [PATCH 29/39] Remove empty sections in to_file_ --- loam/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/loam/base.py b/loam/base.py index cd41a9a..02cf28f 100644 --- a/loam/base.py +++ b/loam/base.py @@ -273,5 +273,7 @@ def to_file_( if entry.to_str is not None: value = entry.to_str(value) to_dump[sec_name][fld.name] = value + if not to_dump[sec_name]: + del to_dump[sec_name] with path.open('w') as pf: toml.dump(to_dump, pf) From 3c5832865b6323c76041ed873ad4b02ef6478df3 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Thu, 28 Apr 2022 00:52:27 +0100 Subject: [PATCH 30/39] Documentation: autodoc for base instead of manager --- docs/index.rst | 2 +- docs/sources/api/base.rst | 5 +++++ docs/sources/api/manager.rst | 5 ----- 3 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 docs/sources/api/base.rst delete mode 100644 docs/sources/api/manager.rst diff --git a/docs/index.rst b/docs/index.rst index 1e2521f..3cb7eaf 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,7 +16,7 @@ Welcome to loam's documentation! :maxdepth: 2 :caption: API Reference - sources/api/manager + sources/api/base sources/api/cli sources/api/tools sources/api/error diff --git a/docs/sources/api/base.rst b/docs/sources/api/base.rst new file mode 100644 index 0000000..f587365 --- /dev/null +++ b/docs/sources/api/base.rst @@ -0,0 +1,5 @@ +base +==== + +.. automodule:: loam.base + :members: diff --git a/docs/sources/api/manager.rst b/docs/sources/api/manager.rst deleted file mode 100644 index 12809cc..0000000 --- a/docs/sources/api/manager.rst +++ /dev/null @@ -1,5 +0,0 @@ -manager -======= - -.. automodule:: loam.manager - :members: From 947f6c5b30778d4128644b915158ea2043dcb14f Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Thu, 28 Apr 2022 00:56:27 +0100 Subject: [PATCH 31/39] Update copyright year --- LICENSE | 2 +- docs/conf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 849f0d4..3db6b27 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018-2021 Adrien Morison +Copyright (c) 2018-2022 Adrien Morison Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docs/conf.py b/docs/conf.py index c6b5958..dd134d2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -40,7 +40,7 @@ # -- Project information ----------------------------------------------------- project = 'loam' -copyright = '2018 - 2021, Adrien Morison' +copyright = '2018 - 2022, Adrien Morison' author = 'Adrien Morison' # The full version, including alpha/beta/rc tags. From 1179d946b9815b2a9fedbfe383c1c2ffc71458ba Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Thu, 28 Apr 2022 01:28:36 +0100 Subject: [PATCH 32/39] Update first steps documentation --- docs/sources/firststeps.rst | 61 ++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/docs/sources/firststeps.rst b/docs/sources/firststeps.rst index f371f76..41c411d 100644 --- a/docs/sources/firststeps.rst +++ b/docs/sources/firststeps.rst @@ -17,34 +17,33 @@ configuration object with no config file nor argument parsing management. :: - from loam.manager import ConfigurationManager, ConfOpt - - # A simple dictionary define all the options and their default values. - # The first level of keys are the section names, the second level of keys - # are the option names. Note that you can have the same option name living - # in two different sections. - conf_def = { - 'sectionA': {'optionA': ConfOpt('foo'), - 'optionB': ConfOpt(42), - 'optionC': ConfOpt('bar')}, - 'sectionB': {'optionD': ConfOpt(None), - 'optionA': ConfOpt(3.14156)} - } - - conf = ConfigurationManager.from_dict_(conf_def) - - # you can access options value with attribute or item notation - assert conf.sectionA.optionA is conf.sectionA['optionA'] - assert conf.sectionA is conf['sectionA'] - - # you can set values (with attribute or item notation) - conf.sectionA.optionA = 'baz' - # and then reset it to its default value - del conf.sectionA.optionA - assert conf.sectionA.optionA == 'foo' - # you can also reset entire sections at once - del conf.sectionA - # or even all configuration options (note that all methods of - # ConfigurationManager have a postfixed _ to minimize the risk of collision - # with your section or option names). - conf.reset_() + from dataclasses import dataclass + from typing import Optional + + from loam.base import entry, Section, ConfigBase + + # Dataclasses define the options and their default values. + @dataclass + class SectionA(Section): + option_a: str = "foo" + option_b: int = 42 + option_c: str = "bar" + + # You can attach metadata to each option, such as an explanation + @dataclass + class SectionB(Section): + option_d: int = entry(val=0, doc="some number") + # you can have the same option name living in two different sections + option_a: float = entry(val=3.14159, doc="some float") + + # A ConfigBase dataclass groups the sections + @dataclass + class Config(ConfigBase): + section_a: SectionA + section_b: SectionB + + conf = Config.default_() + + # You can access options value and modify them + assert conf.section_a.option_a == "foo" + conf.section_b.option_d = 3 From 18b021ee0861e30c4c2e3621256c86cada1a7b6f Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Thu, 28 Apr 2022 01:30:56 +0100 Subject: [PATCH 33/39] Add parsers API doc --- docs/index.rst | 1 + docs/sources/api/parsers.rst | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 docs/sources/api/parsers.rst diff --git a/docs/index.rst b/docs/index.rst index 3cb7eaf..312d361 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,6 +18,7 @@ Welcome to loam's documentation! sources/api/base sources/api/cli + sources/api/parsers sources/api/tools sources/api/error diff --git a/docs/sources/api/parsers.rst b/docs/sources/api/parsers.rst new file mode 100644 index 0000000..124ffb6 --- /dev/null +++ b/docs/sources/api/parsers.rst @@ -0,0 +1,5 @@ +parsers +======= + +.. automodule:: loam.parsers + :members: From e76a2c7f9626bd2415c59eacc9237565fb756ae3 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Thu, 28 Apr 2022 01:31:59 +0100 Subject: [PATCH 34/39] Rename list_of into tuple_of --- loam/parsers.py | 4 ++-- tests/test_parsers.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/loam/parsers.py b/loam/parsers.py index 4636e07..8f70337 100644 --- a/loam/parsers.py +++ b/loam/parsers.py @@ -59,12 +59,12 @@ def slice_or_int_parser(arg: str) -> Union[slice, int]: return int(arg) -def list_of( +def tuple_of( from_str: Callable[[str], T], sep: str = ',' ) -> Callable[[str], Tuple[T, ...]]: """Return a parser of a comma-separated list of a given type. - For example, `list_of(float)` can be use to parse `"3.2,4.5,12.8"` as + For example, `tuple_of(float)` can be use to parse `"3.2,4.5,12.8"` as `(3.2, 4.5, 12.8)`. Each element is stripped before parsing, meaning `"3.2, 4.5, 12.8"` will also be accepted by the parser. diff --git a/tests/test_parsers.py b/tests/test_parsers.py index ae64480..33d54fd 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -32,11 +32,11 @@ def test_slice_parser(): assert parsers.slice_parser("::5") == slice(None, None, 5) -def test_list_of(): - lfloat = parsers.list_of(float) +def test_tuple_of(): + lfloat = parsers.tuple_of(float) assert lfloat("3.2,4.5,12.8") == (3.2, 4.5, 12.8) assert lfloat("42") == (42.,) assert lfloat("78e4, 12,") == (7.8e5, 12.) assert lfloat("") == tuple() - lint = parsers.list_of(int, ";") + lint = parsers.tuple_of(int, ";") assert lint("3;4") == (3, 4) From 6e887ec00e69a7bed5ec6e5c5b6973c355d286f5 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Thu, 28 Apr 2022 01:38:03 +0100 Subject: [PATCH 35/39] Remove unused LoamError classes --- loam/error.py | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/loam/error.py b/loam/error.py index f30e181..ef18545 100644 --- a/loam/error.py +++ b/loam/error.py @@ -13,36 +13,6 @@ class LoamWarning(UserWarning): pass -class SectionError(LoamError): - """Raised when invalid config section is requested. - - Args: - section: invalid section name. - - Attributes: - section: invalid section name. - """ - - def __init__(self, section: str): - self.section = section - super().__init__(f'invalid section name: {section}') - - -class OptionError(LoamError): - """Raised when invalid config option is requested. - - Args: - option: invalid option name. - - Attributes: - option: invalid option name. - """ - - def __init__(self, option: str): - self.option = option - super().__init__(f'invalid option name: {option}') - - class SubcmdError(LoamError): """Raised when an invalid Subcmd name is requested. From e34c387621bab63ad93ea6bcf0841a55577a556c Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Thu, 28 Apr 2022 01:53:00 +0100 Subject: [PATCH 36/39] Minor doc fixes --- loam/base.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/loam/base.py b/loam/base.py index 02cf28f..7b4ca87 100644 --- a/loam/base.py +++ b/loam/base.py @@ -23,13 +23,14 @@ class Entry(Generic[T]): """Metadata of configuration options. Attributes: - val: default value. Use :attr`val_str` or :attr:`val_factory` instead + val: default value. Use :attr:`val_str` or :attr:`val_factory` instead if it is mutable. val_str: default value from a string representation. This requires :attr:`from_str`. The call to the latter is wrapped in a function to avoid issues if the obtained value is mutable. - val_factory: default value wrapped in a function, this is - useful if the default value is mutable. + val_factory: default value wrapped in a function, this is useful if the + default value is mutable. This can be used to set a default value + of `None`: `val_factory=lambda: None`. doc: short description of the option. in_file: whether the option can be set in the config file. in_cli: whether the option is a command line argument. From 46d2ce2f1d42383ffbc28a02530a9a70799e3a18 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Thu, 28 Apr 2022 02:10:29 +0100 Subject: [PATCH 37/39] Test elision of section with no in_file opts --- tests/conftest.py | 7 +++++++ tests/test_base.py | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index b3dfa71..a3d2227 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -78,10 +78,17 @@ def section_b() -> SectionB: return SectionB() +@dataclass +class SectionNotInFile(Section): + some_int: int = entry(val=0, in_file=False) + some_str: str = entry(val="baz", in_file=False) + + @dataclass class MyConfig(ConfigBase): section_a: SectionA section_b: SectionB + section_not_in_file: SectionNotInFile @pytest.fixture diff --git a/tests/test_base.py b/tests/test_base.py index f922bed..d4de6eb 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -118,7 +118,9 @@ def test_to_toml_not_in_file(my_config, tmp_path): toml_file = tmp_path / "conf.toml" my_config.section_b.some_str = "ignored" my_config.to_file_(toml_file) - assert "ignored" not in toml_file.read_text() + content = toml_file.read_text() + assert "ignored" not in content + assert "section_not_in_file" not in content def test_from_toml_not_in_file(my_config, tmp_path): From 0ae4135a6b8d56ba5ca500397b995e9b13228824 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Thu, 28 Apr 2022 02:34:48 +0100 Subject: [PATCH 38/39] New tools.path_entry to define a path option --- loam/tools.py | 37 ++++++++++++++++++++++++++++++++++--- tests/conftest.py | 4 ++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/loam/tools.py b/loam/tools.py index 3317e4e..46a4118 100644 --- a/loam/tools.py +++ b/loam/tools.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -import pathlib +from pathlib import Path import subprocess import shlex import typing @@ -11,13 +11,44 @@ from .base import Entry, Section if typing.TYPE_CHECKING: - from pathlib import Path from typing import Optional, Union, Type from os import PathLike from .base import ConfigBase from .cli import CLIManager +def path_entry( + path: Union[str, PathLike], + doc: str, + in_file: bool = True, + in_cli: bool = True, + cli_short: Optional[str] = None, + cli_zsh_only_dirs: bool = False, + cli_zsh_comprule: Optional[str] = None +) -> Path: + """Define a path option. + + This creates a path option. See :class:`loam.base.Entry` for the meaning of + the arguments. By default, the zsh completion rule completes any file. You + can switch this to only directories with the `cli_zsh_only_dirs` option, or + set your own completion rule with `cli_zsh_comprule`. + """ + if cli_zsh_comprule is None: + cli_zsh_comprule = "_files" + if cli_zsh_only_dirs: + cli_zsh_comprule += " -/" + return Entry( + val=Path(path), + doc=doc, + from_str=Path, + to_str=str, + in_file=in_file, + in_cli=in_cli, + cli_short=cli_short, + cli_zsh_comprule=cli_zsh_comprule, + ).field() + + def switch_opt(default: bool, shortname: Optional[str], doc: str) -> bool: """Define a switchable option. @@ -107,7 +138,7 @@ def create_complete_files(climan: CLIManager, path: Union[str, PathLike], zsh_force_grouping: if True, assume zsh supports grouping of options. Otherwise, loam will attempt to check whether zsh >= 5.4. """ - path = pathlib.Path(path) + path = Path(path) zsh_dir = path / 'zsh' zsh_dir.mkdir(parents=True, exist_ok=True) zsh_file = zsh_dir / f"_{cmd}.sh" diff --git a/tests/conftest.py b/tests/conftest.py index a3d2227..4a3c775 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ import pytest from loam.cli import Subcmd, CLIManager -from loam.tools import switch_opt +from loam.tools import switch_opt, path_entry from loam.base import entry, Section, ConfigBase @@ -69,7 +69,7 @@ def section_a() -> SectionA: @dataclass class SectionB(Section): - some_path: Path = entry(val=Path(), to_str=str) + some_path: Path = path_entry(".", "") some_str: str = entry(val="bar", in_file=False) From dc02513d838d2456044abf9c6d6fea5fd49fea67 Mon Sep 17 00:00:00 2001 From: Adrien Morison Date: Thu, 28 Apr 2022 02:44:51 +0100 Subject: [PATCH 39/39] Use cfile fixture where relevant --- tests/test_base.py | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/tests/test_base.py b/tests/test_base.py index d4de6eb..6dd76a4 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -4,7 +4,6 @@ from typing import Optional import pytest -import toml from loam.base import entry, Section, ConfigBase @@ -104,39 +103,34 @@ def test_config_default(my_config): assert my_config.section_b.some_str == "bar" -def test_to_from_toml(my_config, tmp_path): - toml_file = tmp_path / "conf.toml" +def test_to_from_toml(my_config, cfile): my_config.section_a.some_n = 5 my_config.section_b.some_path = Path("foo/bar") - my_config.to_file_(toml_file) + my_config.to_file_(cfile) new_config = my_config.default_() - new_config.update_from_file_(toml_file) + new_config.update_from_file_(cfile) assert my_config == new_config -def test_to_toml_not_in_file(my_config, tmp_path): - toml_file = tmp_path / "conf.toml" +def test_to_toml_not_in_file(my_config, cfile): my_config.section_b.some_str = "ignored" - my_config.to_file_(toml_file) - content = toml_file.read_text() + my_config.to_file_(cfile) + content = cfile.read_text() assert "ignored" not in content assert "section_not_in_file" not in content -def test_from_toml_not_in_file(my_config, tmp_path): - toml_file = tmp_path / "conf.toml" - with toml_file.open("w") as tf: - toml.dump({"section_b": {"some_str": "ignored"}}, tf) - my_config.default_().update_from_file_(toml_file) +def test_from_toml_not_in_file(my_config, cfile): + cfile.write_text('[section_b]\nsome_str="ignored"\n') + my_config.default_().update_from_file_(cfile) assert my_config.section_b.some_str == "bar" -def test_to_file_exist_ok(my_config, tmp_path): - toml_file = tmp_path / "conf.toml" - my_config.to_file_(toml_file) +def test_to_file_exist_ok(my_config, cfile): + my_config.to_file_(cfile) with pytest.raises(RuntimeError): - my_config.to_file_(toml_file, exist_ok=False) - my_config.to_file_(toml_file) + my_config.to_file_(cfile, exist_ok=False) + my_config.to_file_(cfile) def test_config_with_not_section():