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

Subclass checks #94

Open
hjalmarlucius opened this issue Jul 5, 2022 · 10 comments
Open

Subclass checks #94

hjalmarlucius opened this issue Jul 5, 2022 · 10 comments
Assignees
Labels
enhancement_low A feature or request which should be adopted with low priority

Comments

@hjalmarlucius
Copy link

It would be great if there were a function to check if defaultdict[str, float] is a subclass of dict[str, float]. Use case: Descriptors ensuring (done in __set_name__) that the typing of parent classes is Liskov Substition consistent with child classes.

@FelixTheC FelixTheC added the enhancement_low A feature or request which should be adopted with low priority label Jul 5, 2022
@FelixTheC FelixTheC added this to To Do in strongtyping via automation Jul 5, 2022
@FelixTheC FelixTheC self-assigned this Jul 5, 2022
@FelixTheC FelixTheC moved this from To Do [Medium Priority] to To Do [Low Priority] in strongtyping Jul 5, 2022
@FelixTheC
Copy link
Owner

Could you please provide a test scenario for a better/more detailed understanding?

@hjalmarlucius
Copy link
Author

hjalmarlucius commented Jul 5, 2022

An attempted solution using py3.10 but there's surely some holes:

from typing import get_args
from typing import get_origin
from typing import Any
from typing import Union

def check_subclass(child_type: Any, parent_type: Any) -> bool:
    if parent_type is Any or parent_type is child_type:
        return True
    parent_origin = get_origin(parent_type)
    child_origin = get_origin(child_type)
    if parent_origin is None:
        return issubclass(child_origin or child_type, parent_type)
    parent_args = get_args(parent_type)
    child_args = get_args(child_type)
    if parent_origin is Union:
        if child_origin is Union:
            return all(
                any(check_subclass(ch, pa) for pa in parent_args) for ch in child_args
            )
        return any(check_subclass(child_type, parent_arg) for parent_arg in parent_args)
    if child_origin is None:
        return False
    if not issubclass(child_origin, parent_origin):
        return False
    try:
        for childarg, parentarg in zip(child_args, parent_args, strict=True):
            if not check_subclass(childarg, parentarg):
                return False
    except ValueError:
        return False
    return True

@hjalmarlucius
Copy link
Author

I prefer to build code using lightweight libraries like yours rather than more feature-rich systems like pydantic and typeguard. What I really want is a drop-in replacement for issubclass and isinstance that is compatible with mypy.

I've built a lightweight, pydantic-like descriptor that validates attributes, e.g.:

from numbers import Real
from numbers import Complex
from typing import Optional
from typing import Union
from hjarl.simulator import Entity
from hjarl.simulator import constant


class X(Entity):
    x = constant(dict[int, str])
    y = constant(Optional[int])
    z = constant(Union[Real, str])


class Y(X):
    x = constant(dict[int, str])
    y = constant(int)
    z = constant(Union[Complex, str])
#  ^ error on z since Complex is not compatible with Real

@FelixTheC
Copy link
Owner

So we need a separate decorator or new option/parameter for the @match_class_typing 🤔

@hjalmarlucius
Copy link
Author

I wasn't really thinking of the decorator (I only used utils the functionality) but only a function that can check (reasonably well) subclassing (and isinstance) from type hints. I haven't found any library that solves this pretty simple problem without also adding a ton of complexity (particularly pydantic).

@FelixTheC
Copy link
Owner

FelixTheC commented Aug 8, 2022

This is only an example without any tests and polish

from numbers import Real, Complex
from typing import Optional, Union

from strongtyping.types import Entity, Constant


class X(Entity):
    x: Constant[dict[int, str]]
    y: Constant[Optional[float]]
    z: Constant[Union[Real, str]]


class Y(X):
    x: Constant[dict[int, str]]
    y: Constant[int]
    z: Constant[Union[Complex, str]]

will raise at compile time
AttributeError: strong_typing_utils.Constant[typing.Union[numbers.Complex, str]] does not match with same Attribute in 'X'

Will this solve the issue in the way you expect??

The overhead is quite small
here is the current not ready for release pre-view

class Entity:

    def __init_subclass__(cls, **kwargs):
        if cls.__mro__[1] == Entity:
            return cls
        else:
            sub_cls = cls.__mro__[0]
            for parent in cls.__mro__[1:]:
                parent_annotations = parent.__annotations__
                for key, val in sub_cls.__annotations__.items():
                    if parent_val := parent_annotations.get(key):
                        if val != parent_val:
                            raise AttributeError(f"{val} does not match with same Attribute in {parent.__name__!r}")

                if parent.__class__ is Entity.__class__:
                    break
        return cls

this will be called later and do the checks

def check_typing_type(arg_typ, other_typ, *args, **kwargs):
    arg_origins = get_origins(arg_typ)
    other_origins = get_origins(other_typ)
    if arg_origins != other_origins:
        if 'Optional' in arg_origins or 'Optional' in other_origins:
            check = True
            possible_args = get_possible_types(arg_typ) or (arg_typ,)
            possible_other = get_possible_types(other_typ) or (other_typ,)

            for arg, other in zip_longest(possible_args, possible_other):
                if arg is not None and other is not None:
                    if arg is not other and other in ORIGINAL_DUCK_TYPES:
                        check = other in ORIGINAL_DUCK_TYPES[arg]
                        if not check:
                            break
                    else:
                        check = arg is other
                        if not check:
                            break
            return check
        return False
    else:
        check = True
        possible_args = get_possible_types(arg_typ) or (arg_typ,)
        possible_other = get_possible_types(other_typ) or (other_typ,)
        for arg, other in zip_longest(possible_args, possible_other):
            try:
                check = issubclass(arg, other)
            except TypeError:
                # continue with nested values
                pass
            else:
                if not check:
                    break
    return check

@FelixTheC
Copy link
Owner

@hjalmarlucius what do you think about it??

@hjalmarlucius
Copy link
Author

hjalmarlucius commented Aug 13, 2022

I only reviewed the interface but think it's great - very similar to what I've built on my private system as well. However, I'm using descriptors instead of type hints since I felt that hints required a more hacky solution whereas descriptors had a very nice interface via __set_name__.

A question: Why do you match against the string representations instead of the types themselves? Ref my own check_type and check_subclass (both probably has some holes - I put it together long ago but haven't tested it extensively):

from typing import get_args
from typing import get_origin

custom_typechecks: dict[Any, Callable[[Any, tuple[Any, ...]], bool]] = {}

def check_type(tgt: Any, cls: Any) -> bool:
    if cls is Any:
        return True
    if cls is None:
        return tgt is None
    if cls is type:
        return type(tgt) is type
    args = tuple(arg for arg in get_args(cls) if not isinstance(arg, TypeVar))
    if (origin := get_origin(cls)) is None:
        return isinstance(tgt, cls)
    if origin in (Union, UnionType):
        return any(check_type(tgt, arg) for arg in args)
    if origin is Literal:
        return tgt in args
    if origin is type:
        return type(tgt) is type
    if not isinstance(tgt, origin):
        return False
    if (customcheck := custom_typechecks.get(origin, None)) is not None:
        return customcheck(tgt, args)
    if origin in (dict, defaultdict, Mapping):
        assert len(args) == 2
        keytype, valuetype = args
        return all(
            check_type(k, keytype) and check_type(v, valuetype) for k, v in tgt.items()
        )
    if origin is tuple:
        if Ellipsis in args:
            assert len(args) == 2
            valuetype, _ = args
            return all(check_type(v, valuetype) for v in tgt)
        try:
            return all(check_type(v, t) for v, t in zip(tgt, args, strict=True))
        except ValueError:
            return False
    if origin in (list, set, frozenset):
        assert len(args) == 1
        (valuetype,) = args
        return all(check_type(v, valuetype) for v in tgt)
    return False


def check_subclass(child_type: Any, parent_type: Any) -> bool:
    if parent_type is Any or parent_type is child_type:
        return True
    parent_origin = get_origin(parent_type)
    child_origin = get_origin(child_type)
    if parent_origin is None:
        return issubclass(child_origin or child_type, parent_type)
    parent_args = get_args(parent_type)
    child_args = get_args(child_type)
    if parent_origin is Union:
        if child_origin is Union:
            return all(
                any(check_subclass(ch, pa) for pa in parent_args) for ch in child_args
            )
        return any(check_subclass(child_type, parent_arg) for parent_arg in parent_args)
    if parent_origin is Literal:
        if child_origin is Literal:
            return set(child_args).issubset(parent_args)
        return False
    if child_origin is Literal:
        return all(isinstance(ch, parent_origin) for ch in child_args)
    if child_origin is None:
        return False
    if not issubclass(child_origin, parent_origin):
        return False
    try:
        for childarg, parentarg in zip(child_args, parent_args, strict=True):
            if not check_subclass(childarg, parentarg):
                return False
    except ValueError:
        return False
    return True

@FelixTheC
Copy link
Owner

Good question, I started it a while ago I should start a deeper refactoring in the near future.

I had trouble with mypy when using descriptors instead of a Type that was the only reason.

I think you want to use your own version so that I will close this issue??

@hjalmarlucius
Copy link
Author

hjalmarlucius commented Aug 13, 2022

Yeah I'm a bit stuck on migrating as I've ventured too far on my own path but would ideally offload all of this eventually. Agree that there's definitely a trade-off required between annotations and descriptors that should be cleaned up in python core - i.e. the ability to create your own dataclass without tons of hacks.

@FelixTheC FelixTheC moved this from To Do [Low Priority] to To Do [Medium Priority] in strongtyping Oct 6, 2022
@FelixTheC FelixTheC moved this from To Do [Medium Priority] to To Do [Low Priority] in strongtyping Oct 6, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement_low A feature or request which should be adopted with low priority
Projects
strongtyping
  
To Do [Low Priority]
Development

When branches are created from issues, their pull requests are automatically linked.

2 participants