Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Error with from __future__ import annotations #43

Open
LEv145 opened this issue May 31, 2022 · 6 comments
Open

Error with from __future__ import annotations #43

LEv145 opened this issue May 31, 2022 · 6 comments

Comments

@LEv145
Copy link

LEv145 commented May 31, 2022

from __future__ import annotations

import dataconf

from dataclasses import dataclass


@dataclass
class Model():
    token: str
    
dataconf.env("TEST_", Model)

Error:

Traceback (most recent call last):
  File "main.py", line 13, in <module>
    dataconf.env("ITBUTKA_", Model)
  File "/.venv/lib/python3.9/site-packages/dataconf/main.py", line 64, in env
    return multi.env(prefix, **kwargs).on(clazz)
  File "/.venv/lib/python3.9/site-packages/dataconf/main.py", line 57, in on
    return parse(conf, clazz, self.strict, **self.kwargs)
  File "/.venv/lib/python3.9/site-packages/dataconf/main.py", line 16, in parse
    return utils.__parse(conf, clazz, "", strict, ignore_unexpected)
  File "/.venv/lib/python3.9/site-packages/dataconf/utils.py", line 68, in __parse
    fs[f.name] = __parse(
  File "/.venv/lib/python3.9/site-packages/dataconf/utils.py", line 186, in __parse
    for child_clazz in sorted(clazz.__subclasses__(), key=lambda c: c.__name__):
AttributeError: 'str' object has no attribute '__subclasses__'

Process finished with exit code 1
@LEv145 LEv145 changed the title Bug with from __future__ import annotations Error with from __future__ import annotations May 31, 2022
zifeo added a commit that referenced this issue Jun 1, 2022
@zifeo
Copy link
Owner

zifeo commented Jun 1, 2022

@LEv145 Thank you for your report. PEP 563 (and its backport from __future__ import annotations) seems to introduce a change in annotations evaluation that will have a wide impact on type based logic/libraries.

You can find an attempt to support the change the this branch:

pip install --upgrade git+https://github.com/zifeo/dataconf.git@annotations
poetry add git+https://github.com/zifeo/dataconf.git@annotations

However as this change has been delayed (3.10), is still a bit buggy (see Python bug tracker) and does not offer (yet?) an elegant way around, I will wait until more info and stability are available. The final fix will likely be somehow different.

@zifeo
Copy link
Owner

zifeo commented Jul 24, 2022

Dataconf 2.0.0 has been back-ported to support branch.

@ghost
Copy link

ghost commented Sep 19, 2023

This is broken again on 2.2.1, unfortunately. (Python 3.11, MacOS).

@zifeo
Copy link
Owner

zifeo commented Sep 24, 2023

@dargueta-copilotiq v2.2.2 has been backported, let me know if you find any issue

@ghost
Copy link

ghost commented Sep 27, 2023

Looks good to me. Thanks!

@dargueta-accompanyhealth
Copy link

dargueta-accompanyhealth commented Feb 6, 2025

The crash came back, unfortunately. It only happens when from __future__ import annotations is present.

  • Python: 3.10.16, 3.11.11, 3.12.8, 3.13.1
  • dataconf: 3.3.0
  • OS: MacOS 15.1.1, Amazon Linux

The code:

# Comment this line out and it works
from __future__ import annotations

@dataclasses.dataclass
class BasicClass:
    qwer: str

    @classmethod
    def from_cli(cls, argv= (), defaults= None):
        loader = dataconf.multi.cli(argv or sys.argv)  # type: ignore[arg-type]
        if defaults:
            loader = loader.dict(defaults)  # type: ignore[arg-type]
        return loader.on(cls)  # type: ignore[no-any-return]

settings = BasicClass.from_cli(["--qwer", "one"])

Traceback:

>       settings = BasicClass.from_cli(["--qwer", "one"])

tests/jobs_test.py:52: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
tests/jobs_test.py:41: in from_cli
    return loader.on(cls)  # type: ignore[no-any-return]
.tox/py312/lib/python3.12/site-packages/dataconf/main.py:93: in on
    return parse(conf, clazz, self.strict, **self.kwargs)
.tox/py312/lib/python3.12/site-packages/dataconf/main.py:26: in parse
    return utils.__parse(conf, clazz, "", strict, ignore_unexpected)
.tox/py312/lib/python3.12/site-packages/dataconf/utils.py:91: in __parse
    fs[f.name] = __parse(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

value = 'one', clazz = 'str', path = '.qwer', strict = True, ignore_unexpected = False

    def __parse(value: any, clazz: Type, path: str, strict: bool, ignore_unexpected: bool):
        if is_dataclass(clazz):
            if not isinstance(value, ConfigTree):
                raise TypeConfigException(
                    f"expected type {clazz} at {path}, got {type(value)}"
                )
    
            fs = {}
            renamings = dict()
    
            for f in fields(clazz):
                if f.name in value:
                    val = value[f.name]
                elif f.name.replace("_", "-") in value:
                    renamings[f.name] = f.name.replace("_", "-")
                    val = value[f.name.replace("_", "-")]
                else:
                    if callable(f.default_factory):
                        val = f.default_factory()
                    else:
                        val = f.default
                    if is_dataclass(val):
                        # if val is a dataclass, convert to ConfigTree
                        val = ConfigTree(asdict(val))
    
                if not isinstance(val, _MISSING_TYPE):
                    fs[f.name] = __parse(
                        val, f.type, f"{path}.{f.name}", strict, ignore_unexpected
                    )
    
                elif is_optional(f.type):
                    # Optional not found
                    fs[f.name] = None
    
                else:
                    raise MalformedConfigException(
                        f"expected type {clazz} at {path}, no {f.name} found in dataclass"
                    )
    
            unexpected_keys = value.keys() - {renamings.get(k, k) for k in fs.keys()}
            if len(unexpected_keys) > 0 and not ignore_unexpected:
                raise UnexpectedKeysException(
                    f"unexpected key(s) \"{', '.join(unexpected_keys)}\" detected for type {clazz} at {path}"
                )
    
            return clazz(**fs)
    
        origin = get_origin(clazz)
        args = get_args(clazz)
    
        if origin is list:
            if value is None:
                raise MalformedConfigException(f"expected list at {path} but received None")
    
            if len(args) != 1:
                raise MissingTypeException("expected list with type information: List[?]")
    
            parse_candidate = args[0]
            return [
                __parse(v, parse_candidate, f"{path}[]", strict, ignore_unexpected)
                for v in value
            ]
    
        if origin is tuple:
            if value is None:
                raise MalformedConfigException(
                    f"expected tuple at {path} but received None"
                )
    
            if len(args) < 1:
                raise MissingTypeException("expected tuple with type information: Tuple[?]")
    
            has_ellipsis = args[-1] == Ellipsis
            if has_ellipsis and len(args) != 2:
                raise MissingTypeException(
                    "expected one type since ellipsis is used: Tuple[?, ...]"
                )
            _args = args if not has_ellipsis else [args[0]] * len(value)
            if len(value) > 0 and len(value) != len(_args):
                raise MalformedConfigException(
                    "number of provided values does not match expected number of values for tuple."
                )
            return tuple(
                __parse(v, arg, f"{path}[]", strict, ignore_unexpected)
                for v, arg in zip(value, _args)
            )
    
        if origin is dict:
            if len(args) != 2:
                raise MissingTypeException(
                    "expected dict with type information: Dict[?, ?]"
                )
            if value is not None:
                # ignore key type
                parse_candidate = args[1]
                return {
                    k: __parse(v, parse_candidate, f"{path}.{k}", strict, ignore_unexpected)
                    for k, v in value.items()
                }
            return None
    
        if is_union(origin):
            # Optional = Union[T, NoneType]
            has_none = False
            for parse_candidate in args:
                if parse_candidate is NoneType:
                    has_none = True
                else:
                    try:
                        return __parse(
                            value, parse_candidate, path, strict, ignore_unexpected
                        )
                    except TypeConfigException:
                        continue
    
            if has_none:
                return None
    
            raise TypeConfigException(
                f"expected one of {', '.join(map(str, args))} at {path}, got {type(value)}"
            )
    
        if clazz is bool:
            if not strict and value is not None:
                try:
                    value = bool(value)
                except ValueError:
                    pass
            return __parse_type(value, clazz, path, isinstance(value, bool))
    
        if clazz is int:
            if not strict and value is not None:
                try:
                    value = int(value)
                except ValueError:
                    pass
            return __parse_type(value, clazz, path, isinstance(value, int))
    
        if clazz is float:
            if not strict and value is not None:
                try:
                    value = float(value)
                except ValueError:
                    pass
            return __parse_type(
                value, clazz, path, isinstance(value, float) or isinstance(value, int)
            )
    
        if clazz is str:
            return __parse_type(value, clazz, path, isinstance(value, str))
    
        if clazz is Any:
            if type(value) is ConfigTree:
                return dict(value)
    
            return value
    
        if isclass(clazz) and (issubclass(clazz, Enum) or issubclass(clazz, IntEnum)):
            if isinstance(value, int):
                return clazz.__call__(value)
            elif issubclass(clazz, str):
                return clazz(value)
            elif isinstance(value, str):
                return clazz.__getitem__(value)
            raise TypeConfigException(f"expected str or int at {path}, got {type(value)}")
    
        if isclass(clazz) and issubclass(clazz, Path):
            return clazz.__call__(value)
    
        if get_origin(clazz) is (Literal):
            if value in args:
                return value
            raise TypeConfigException(
                f"expected one of {', '.join(map(str, args))} at {path}, got {value}"
            )
    
        if clazz is datetime:
            dt = __parse_type(value, clazz, path, isinstance(value, str))
            try:
                return isoparse(dt)
            except ValueError as e:
                raise ParseException(
                    f"expected type {clazz} at {path}, cannot parse due to {e}"
                )
    
        if clazz is timedelta:
            dt = __parse_type(value, clazz, path, isinstance(value, str))
            try:
                duration = parse_duration(dt)
                if isinstance(duration, Duration):
                    raise ParseException(
                        "The ISO 8601 duration provided can not contain years or months"
                    )
                return duration
            except ValueError as e:
                raise ParseException(
                    f"expected type {clazz} at {path}, cannot parse due to {e}"
                )
    
        if clazz is relativedelta:
            return __parse_type(value, clazz, path, isinstance(value, relativedelta))
    
        child_failures = []
        child_successes = []
>       subtype = value.pop("_type", default=None)
E       AttributeError: 'str' object has no attribute 'pop'

.tox/py312/lib/python3.12/site-packages/dataconf/utils.py:269: AttributeError

The part that crashes is different, but the cause is the same.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants