Skip to content

Commit

Permalink
more tests for construct function
Browse files Browse the repository at this point in the history
  • Loading branch information
jhidding committed May 2, 2024
1 parent 00260d1 commit 7cc1a14
Show file tree
Hide file tree
Showing 3 changed files with 149 additions and 12 deletions.
45 changes: 35 additions & 10 deletions entangled/construct.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def from_str(cls, _: str) -> Self:
def construct(annot: Any, json: Any) -> Any:
try:
return _construct(annot, json)
except (AssertionError, ValueError, KeyError) as e:
except (AssertionError, ValueError, KeyError, TypeError) as e:
raise ConfigError(annot, json) from e


Expand All @@ -47,6 +47,10 @@ def is_optional_type(dtype: Type[Any]) -> TypeGuard[Type[Optional[Any]]]:
)


def normalize_enum(x: str):
return x.upper().replace("-", "_")


def _construct(annot: Type[T], json: Any) -> T:
"""Construct an object from a given type from a JSON stream.
Expand All @@ -70,14 +74,35 @@ def _construct(annot: Type[T], json: Any) -> T:
)
if annot is Any:
return cast(T, json)
# if annot is dict or isgeneric(annot) and typing.get_origin(annot) is dict:
# assert isinstance(json, dict)
# return json
if annot is Path and isinstance(json, str):
return cast(T, Path(json))

# dicts
if isgeneric(annot) and typing.get_origin(annot) is dict:
assert isinstance(json, dict)
return cast(T, {construct(typing.get_args(annot)[0], k): construct(typing.get_args(annot)[1], v)
for k, v in json.items()})
if annot is dict:
assert isinstance(json, dict)
return cast(T, json)

# lists
if isgeneric(annot) and typing.get_origin(annot) is list:
assert isinstance(json, list)
return cast(T, [construct(typing.get_args(annot)[0], item) for item in json])
if annot is list:
assert isinstance(json, list)
return cast(T, json)

# sets
if isgeneric(annot) and typing.get_origin(annot) is set:
assert isinstance(json, list)
return cast(T, {construct(typing.get_args(annot)[0], item) for item in json})
if annot is set:
assert isinstance(json, list)
return cast(T, set(json))

if annot is Path and isinstance(json, str):
return cast(T, Path(json))

if is_optional_type(annot):
if json is None:
return cast(T, None)
Expand All @@ -97,13 +122,13 @@ def _construct(annot: Type[T], json: Any) -> T:
if is_dataclass(annot):
assert isinstance(json, dict)
arg_annot = typing.get_type_hints(annot)
# assert all(k in json for k in arg_annot)
args = {k: construct(arg_annot[k], json[k]) for k in json}
return cast(T, annot(**args))
if isinstance(json, str) and isinstance(annot, type) and issubclass(annot, Enum):
options = {opt.name.lower(): opt for opt in annot}
assert json.lower() in options
return cast(T, options[json.lower()])
options = {normalize_enum(e.name): e for e in annot}
assert isinstance(json, str)
assert normalize_enum(json) in options
return cast(T, options[normalize_enum(json)])
raise ValueError(f"Couldn't construct {annot} from {repr(json)}")


Expand Down
4 changes: 2 additions & 2 deletions entangled/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ def find_watch_dirs():

def list_input_files():
"""List all input files."""
include_file_list = chain.from_iterable(map(Path(".").glob, config.watch_list))
include_file_list = chain.from_iterable(map(Path().glob, config.watch_list))
exclude_file_list = list(
chain.from_iterable(map(Path(".").glob, config.ignore_list))
chain.from_iterable(map(Path().glob, config.ignore_list))
)
return [path for path in include_file_list if not path in exclude_file_list]

Expand Down
112 changes: 112 additions & 0 deletions test/test_construct.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from __future__ import annotations

from entangled.construct import (construct, FromStr)
from entangled.errors.user import ConfigError
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
import pytest


def test_primitives():
assert construct(dict, {"a": 3}) == {"a": 3}
assert construct(bool, True) == True
assert construct(str, "hello") == "hello"
assert construct(list, [1, 2, 3, 3]) == [1, 2, 3, 3]
assert construct(set, [1, 2, 3, 3]) == {1, 2, 3}

with pytest.raises(ConfigError):
construct(str, True)
with pytest.raises(ConfigError):
construct(list, {"a": 3})


def test_typed_primitives():
assert construct(dict[str, int], {"a": 3}) == {"a": 3}
assert construct(list[int], [1, 2, 3, 3]) == [1, 2, 3, 3]
assert construct(set[int], [1, 2, 3, 3]) == {1, 2, 3}

with pytest.raises(ConfigError):
construct(list[int], ["1", "2", "3"])


def test_paths():
assert construct(Path, "/usr/bin/python") == Path("/usr/bin/python")


@dataclass
class Email(FromStr):
user: str
domain: str

@classmethod
def from_str(cls, s: str) -> Email:
parts = s.split("@")
assert len(parts) == 2
user, domain = parts
return Email(user, domain)


def test_email():
assert construct(Email, "john@doe") == Email("john", "doe")
assert construct(Email, {"user": "john", "domain": "doe"}) == Email("john", "doe")

with pytest.raises(ConfigError):
construct(Email, "john")
with pytest.raises(ConfigError):
construct(Email, 3)


@dataclass
class Opus:
title: str
composer: str
form: str | None = None
poet: str | None = None


def test_optional():
assert construct(Opus, {"title": "Jesu, meine Freude", "composer": "Bach", "form": "motet"}) \
== Opus("Jesu, meine Freude", "Bach", form="motet")
assert construct(Opus, {"title": "Estampes", "composer": "Debussy"}) \
== Opus("Estampes", "Debussy")

with pytest.raises(ConfigError):
construct(Opus, {"title": "Mondschein Sonata"})
with pytest.raises(ConfigError):
construct(Opus, {"title": "Die Forelle", "composer": 3})


@dataclass
class User:
username: str
email: Email


def test_nested_object():
assert construct(User, {"username": "john", "email": "john@doe"}) \
== User("john", Email("john", "doe"))
with pytest.raises(ConfigError):
construct(User, {"username": "john", "email": 4})


class TeaType(Enum):
WHITE = 1
YELLOW = 2
GREEN = 3
OOLONG = 4
BLACK = 5
POST_FERMENTED = 6
SCENTED = 7


def test_enum():
assert construct(TeaType, "white") == TeaType.WHITE
assert construct(TeaType, "post-fermented") == TeaType.POST_FERMENTED
assert construct(TeaType, "post_fermented") == TeaType.POST_FERMENTED
assert construct(TeaType, "POST_FERMENTED") == TeaType.POST_FERMENTED

with pytest.raises(ConfigError):
construct(TeaType, 1)
with pytest.raises(ConfigError):
construct(TeaType, "English-breakfast")

0 comments on commit 7cc1a14

Please sign in to comment.