from pathlib import Path import attr import click.testing import pytest from ruamel.yaml import YAML from corrscope.config import ( yaml, DumpableAttrs, KeywordAttrs, Alias, Ignored, CorrError, CorrWarning, with_units, get_units, _yaml_loadable, ) # YAML Idiosyncrasies: https://docs.saltstack.com/en/develop/topics/troubleshooting/yaml_idiosyncrasies.html # Load/dump infrastructure testing def test_dumpable_attrs(): class Foo(DumpableAttrs): foo: int bar: int s = yaml.dump(Foo(foo=1, bar=2)) assert ( s == """\ !Foo foo: 1 bar: 2 """ ) def test_kw_config(): class Foo(KeywordAttrs): foo: int = 1 bar: int obj = Foo(bar=2) assert obj.foo == 1 assert obj.bar == 2 def test_yaml_object(): @_yaml_loadable class Bar: pass s = yaml.dump(Bar()) assert s == "!Bar {}\n" # Test per-field unit suffixes (used by GUI) def test_unit_suffix(): class Foo(DumpableAttrs): xs: int = with_units("xs") ys: int = with_units("ys", default=2) no_unit: int = 3 # Assert class constructor works. foo = Foo(1, 2, 3) foo_default = Foo(1) # Assert units work. foo_fields = attr.fields(Foo) assert get_units(foo_fields.xs) == " xs" assert get_units(foo_fields.ys) == " ys" assert get_units(foo_fields.no_unit) == "" # Dataclass dump testing def test_dump_defaults(): class Config(DumpableAttrs): a: str = "a" b: str = "b" s = yaml.dump(Config("alpha")) assert ( s == """\ !Config a: alpha """ ) class Config(DumpableAttrs, always_dump="a b"): a: str = "a" b: str = "b" c: str = "c" s = yaml.dump(Config()) assert ( s == """\ !Config a: a b: b """ ) class Config(DumpableAttrs, always_dump="*"): a: str = "a" b: str = "b" s = yaml.dump(Config()) assert ( s == """\ !Config a: a b: b """ ) def test_dump_default_factory(): """Ensure default factories are not dumped, unless attribute present in `always_dump`. Based on `attrs.Factory`.""" class Config(DumpableAttrs): # Equivalent to attr.ib(factory=str) # See https://www.attrs.org/en/stable/types.html a: str = attr.Factory(str) b: str = attr.Factory(str) s = yaml.dump(Config("alpha")) assert ( s == """\ !Config a: alpha """ ) class Config(DumpableAttrs, always_dump="a b"): a: str = attr.Factory(str) b: str = attr.Factory(str) c: str = attr.Factory(str) s = yaml.dump(Config()) assert ( s == """\ !Config a: '' b: '' """ ) class Config(DumpableAttrs, always_dump="*"): a: str = attr.Factory(str) b: str = attr.Factory(str) s = yaml.dump(Config()) assert ( s == """\ !Config a: '' b: '' """ ) def test_always_dump_inheritance(): class Config(DumpableAttrs, always_dump="a"): a: int = 1 b: int = 2 class Special(Config, always_dump="c"): c: int = 3 d: int = 4 s = yaml.dump(Special()) assert ( s == """\ !Special a: 1 c: 3 """ ) def test_exclude_dump(): """ Test that the exclude="" parameter can remove fields from always_dump="*". """ class Config(DumpableAttrs, always_dump="*", exclude="b"): a: int = 1 b: int = 2 s = yaml.dump(Config()) assert ( s == """\ !Config a: 1 """ ) class Special(Config, exclude="d"): c: int = 3 d: int = 4 s = yaml.dump(Special()) assert ( s == """\ !Special a: 1 c: 3 """ ) # Dataclass load testing def test_dump_load_aliases(): """Ensure dumping and loading `xx=Alias('x')` works. Ensure loading `{x=1, xx=1}` raises an error. Does not check constructor `Config(xx=1)`.""" class Config(DumpableAttrs, kw_only=False): x: int xx = Alias("x") # Test dumping assert len(attr.fields(Config)) == 1 cfg = Config(1) s = yaml.dump(cfg) assert ( s == """\ !Config x: 1 """ ) assert yaml.load(s) == cfg # Test loading s = """\ !Config xx: 1 """ assert yaml.load(s) == Config(x=1) # Test exception on duplicated parameters. s = """\ !Config x: 1 xx: 1 """ with pytest.raises(CorrError): yaml.load(s) def test_dump_load_ignored(): """Ensure loading `xx=Ignored` works. Does not check constructor `Config(xx=1)`. """ class Config(DumpableAttrs): xx = Ignored # Test dumping assert len(attr.fields(Config)) == 0 cfg = Config() s = yaml.dump(cfg) assert ( s == """\ !Config {} """ ) assert yaml.load(s) == cfg # Test loading s = """\ !Config xx: 1 """ assert yaml.load(s) == Config() def test_load_argument_validation(): """Ensure that loading config via YAML catches missing parameters.""" class Config(DumpableAttrs): a: int yaml.load( """\ !Config a: 1 """ ) with pytest.raises(TypeError): yaml.load("!Config {}") def test_ignore_unrecognized_fields(): """Ensure unrecognized fields yield warning, not exception.""" class Foo(DumpableAttrs): foo: int s = """\ !Foo foo: 1 bar: 2 """ with pytest.warns(CorrWarning): assert yaml.load(s) == Foo(1) def test_load_post_init(): """yaml.load() does not natively call __init__. So DumpableAttrs modifies __setstate__ to call __attrs_post_init__.""" class Foo(DumpableAttrs): foo: int def __attrs_post_init__(self): self.foo = 99 s = """\ !Foo foo: 0 """ assert yaml.load(s) == Foo(99) # Test handling of _prefix fields, or init=False @pytest.mark.filterwarnings("ignore:") def test_skip_dump_load(): """Ensure _fields or init=False are not dumped, and don't crash on loading. Ensure that init=False fields are not retrieved at all. """ class Foo(DumpableAttrs, always_dump="*"): _underscore: int _underscore_default: int = 1 init_false: int = attr.ib(init=False) never_initialized: int = attr.ib(init=False) def __attrs_post_init__(self): self.init_false = 1 # Ensure fields are not dumped. foo = Foo(underscore=1) assert ( yaml.dump(foo) == """\ !Foo underscore: 1 underscore_default: 1 """ ) # Ensure init=False fields don't crash on loading. evil = """\ !Foo underscore: 1 _underscore: 2 init_false: 3 """ assert yaml.load(evil)._underscore == 1 # Test always_dump validation. def test_always_dump_validate(): # Validator not implemented. # with pytest.raises(AssertionError): # class Foo(DumpableAttrs, always_dump="foo foo"): # foo: int with pytest.raises(AssertionError): class Foo(DumpableAttrs, always_dump="* foo"): foo: int with pytest.raises(AssertionError): class Foo(DumpableAttrs, always_dump="bar"): foo: int with pytest.raises(AssertionError): class Foo(DumpableAttrs, exclude="foo"): foo: int with pytest.raises(AssertionError): class Foo(DumpableAttrs, always_dump="*", exclude="bar"): foo: int # Test properties of our ruamel.yaml instance. def test_dump_no_line_break(): """Ensure long paths are not split into multiple lines, at whitespace. yaml.width = float("inf")""" class Foo(DumpableAttrs): long_str: str long_str = "x x" * 500 s = yaml.dump(Foo(long_str)) assert long_str in s # ruamel.yaml has a unstable and shape-shifting API. # Test which version numbers have properties we want. def test_dump_dataclass_order(): class Config(DumpableAttrs, always_dump="*"): a: int = 1 b: int = 1 c: int = 1 d: int = 1 e: int = 1 z: int = 1 y: int = 1 x: int = 1 w: int = 1 v: int = 1 assert ( yaml.dump(Config()) == """\ !Config a: 1 b: 1 c: 1 d: 1 e: 1 z: 1 y: 1 x: 1 w: 1 v: 1 """ ) def test_load_dump_dict_order(): s = """\ a: 1 b: 1 c: 1 d: 1 e: 1 z: 1 y: 1 x: 1 w: 1 v: 1 """ dic = yaml.load(s) assert yaml.dump(dic) == s, yaml.dump(dic) def test_load_list_dict_type(): """Fails on ruamel.yaml<0.15.70 (CommentedMap/CommentedSeq).""" dic = yaml.load("{}") assert isinstance(dic, dict) lis = yaml.load("[]") assert isinstance(lis, list) def test_list_slice_assign(): """Crashes on ruamel.yaml<0.15.55 (CommentedSeq).""" lis = yaml.load("[]") lis[0:0] = list(range(5)) lis[2:5] = [] def test_unicode_dump_load(): """ Crashes on latest ruamel.yaml 0.16.5. https://bitbucket.org/ruamel/yaml/issues/316/unicode-encoding-decoding-errors-on And affects real users when they save "most recent files" containing Unicode. https://github.com/corrscope/corrscope/issues/308 Workaround in MyYAML.dump(), to encode as UTF-8 instead of locale. """ runner = click.testing.CliRunner() with runner.isolated_filesystem(): p = Path("test.yaml") before = {"key": "á😂"} yaml.dump(before, p) after = yaml.load(p) assert after == before def test_loading_corrupted_locale_config(): """ See corrscope.config.MyYAML.load() docstring. """ runner = click.testing.CliRunner() with runner.isolated_filesystem(): raw_yaml = YAML() p = Path("test.yaml") before = {"key": "á"} raw_yaml.dump(before, p) after = yaml.load(p) assert after == before