From 7aa855922b880c6c279596deb0cccedf708b964e Mon Sep 17 00:00:00 2001 From: "s.kovbasa" Date: Sat, 19 Oct 2024 17:31:08 +0300 Subject: [PATCH] WIP: class based api. types --- hiku/classes/__init__.py | 0 hiku/classes/node.py | 160 ++++++++++++++++++++ hiku/classes/strings.py | 10 ++ hiku/classes/types.py | 202 +++++++++++++++++++++++++ hiku/graph.py | 51 ++++--- tests/classes/__init__.py | 0 tests/classes/droid.py | 8 + tests/classes/test_types.py | 291 ++++++++++++++++++++++++++++++++++++ 8 files changed, 703 insertions(+), 19 deletions(-) create mode 100644 hiku/classes/__init__.py create mode 100644 hiku/classes/node.py create mode 100644 hiku/classes/strings.py create mode 100644 hiku/classes/types.py create mode 100644 tests/classes/__init__.py create mode 100644 tests/classes/droid.py create mode 100644 tests/classes/test_types.py diff --git a/hiku/classes/__init__.py b/hiku/classes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hiku/classes/node.py b/hiku/classes/node.py new file mode 100644 index 00000000..34386d14 --- /dev/null +++ b/hiku/classes/node.py @@ -0,0 +1,160 @@ +import dataclasses as dc +import typing +from collections.abc import Hashable +from functools import partial + +import hiku.types +from hiku.classes.strings import to_snake_case +from hiku.directives import SchemaDirective + +""" +@node(...) +class Human: + id: int = field(...) + droid: ref[Droid] = field_link(...) +""" + +_T = typing.TypeVar("_T", bound=Hashable) + + +class NodeProto(typing.Protocol[_T]): + __key__: _T + + +_TNode = typing.TypeVar("_TNode", bound=NodeProto) + + +@dc.dataclass +class HikuNode: + name: str + fields: "list[_HikuField] | list[_HikuFieldLink] | list[_HikuField | _HikuFieldLink]" + description: str | None + directives: list[SchemaDirective] | None + implements: list[str] | None + + +def node( + cls: type[_TNode] | None = None, + *, + name: str | None = None, + description: str | None = None, + directives: list[SchemaDirective] | None = None, + # TODO(s.kovbasa): handle interfaces from mro + implements: list[str] | None = None, +) -> typing.Callable[[type[_TNode]], type[_TNode]] | type[_TNode]: + # TODO(s.kovbasa): add validation and stuff + + def _wrap_cls( + cls: type[_TNode], + name: str | None, + description: str | None, + directives: list[SchemaDirective] | None, + implements: list[str] | None, + ) -> type[_TNode]: + setattr( + cls, + "__hiku_node__", + HikuNode( + name=name or cls.__name__, + fields=_get_fields(cls), + description=description, + directives=directives, + implements=implements, + ), + ) + return cls + + _do_wrap = partial( + _wrap_cls, + name=name, + description=description, + directives=directives, + implements=implements, + ) + + if cls is None: + return _do_wrap + + return _do_wrap(cls) + + +def _get_fields( + cls: type[_TNode], +) -> "list[_HikuField] | list[_HikuFieldLink] | list[_HikuField | _HikuFieldLink]": + # TODO(s.kovbasa): handle name and type from annotations + # TODO(s.kovbasa): first process fields, then links; resolve link requires + return [] + + +@dc.dataclass +class _HikuField: + func: typing.Callable + name: str | None + typ: type + options: object | None + description: str | None + deprecated: str | None + directives: typing.Sequence[SchemaDirective] | None + + +def field( + func: typing.Callable | None = None, + *, + options: object | None = None, + name: str | None = None, + description: str | None = None, + deprecated: str | None = None, + directives: list | None = None, +) -> typing.Any: + return _HikuField( + func=func or resolve_getattr, + name=name, + typ=None, # type: ignore + options=options, + description=description, + deprecated=deprecated, + directives=directives, + ) + + +@dc.dataclass +class _HikuFieldLink: + func: typing.Callable + name: str | None + typ: type + requires_func: typing.Callable[[], tuple] | None + options: object | None + description: str | None + deprecated: str | None + directives: typing.Sequence[SchemaDirective] | None + + +def field_link( + func: typing.Callable | None = None, + *, + options: object | None = None, + requires: typing.Callable[[], tuple[typing.Any, ...]] | None, + name: str | None = None, + description: str | None = None, + deprecated: str | None = None, + directives: list | None = None, +) -> typing.Any: + return _HikuFieldLink( + func=func or direct_link, + name=name, + typ=None, # type: ignore + requires_func=requires, + options=options, + description=description, + deprecated=deprecated, + directives=directives, + ) + + +def resolve_getattr(fields, tuples) -> list[list]: + field_names = [to_snake_case(f.name) for f in fields] + return [[getattr(t, f_name) for f_name in field_names] for t in tuples] + + +def direct_link(ids): + return ids diff --git a/hiku/classes/strings.py b/hiku/classes/strings.py new file mode 100644 index 00000000..b8d5c234 --- /dev/null +++ b/hiku/classes/strings.py @@ -0,0 +1,10 @@ +import re + +UPPER_CAMEL_CASE_BOUNDS_RE = re.compile(r"(.)([A-Z][a-z]+)") +LOWER_CAMEL_CASE_BOUNDS_RE = re.compile(r"([a-z0-9])([A-Z])") + + +# http://stackoverflow.com/a/1176023/1072990 +def to_snake_case(name: str) -> str: + s1 = UPPER_CAMEL_CASE_BOUNDS_RE.sub(r"\1_\2", name) + return LOWER_CAMEL_CASE_BOUNDS_RE.sub(r"\1_\2", s1).lower() diff --git a/hiku/classes/types.py b/hiku/classes/types.py new file mode 100644 index 00000000..7b677d76 --- /dev/null +++ b/hiku/classes/types.py @@ -0,0 +1,202 @@ +import dataclasses as dc +import importlib +import inspect +import types +import typing +from collections.abc import Hashable + +import hiku.graph +import hiku.types + +_T = typing.TypeVar("_T", bound=Hashable) + + +class NodeProto(typing.Protocol[_T]): + __key__: _T + + +_TNode = typing.TypeVar("_TNode", bound=NodeProto) + + +@dc.dataclass +class raw_type: + """ + Helps to update hiku types gradually. E.g. + + id: typing.Annotated[str, hiku.raw_type(hiku.types.ID)] + some_field: typing.Annotated[None, hiku.raw_type(TypeRef["Product"])] + """ + + typ: hiku.types.GenericMeta + + def apply( + self, + container: hiku.types.OptionalMeta | hiku.types.SequenceMeta, + ) -> typing.Self: + return dc.replace(self, typ=container[self.typ]) + + def __hash__(self) -> int: + return hash(self.typ) + + +class lazy: + """ + Allows for a lazy type resolve when circular imports are encountered. + Lazy resolvers are processed during Graph.__init__ + """ + + module: str + package: str | None + + def __init__(self, module: str): + self.module = module + self.package = None + + if module.startswith("."): + current_frame = inspect.currentframe() + assert current_frame is not None + assert current_frame.f_back is not None + + self.package = current_frame.f_back.f_globals["__package__"] + + +class ref(typing.Generic[_TNode]): + """Represents a reference to another object type. + + Is needed in case we someday plan to implement proper mypy checks - this way + we can make use of ref object as a thin wrapper around type's __key__ + """ + + +_BUILTINS_TO_HIKU = { + int: hiku.types.Integer, + float: hiku.types.Float, + str: hiku.types.String, + bool: hiku.types.Boolean, +} + + +@dc.dataclass +class _LazyTypeRef: + """strawberry-like impl for lazy type refs""" + + classname: str + module: str + package: str | None + containers: ( + list[hiku.types.OptionalMeta | hiku.types.SequenceMeta] | None + ) = None + + @property + def typ(self) -> hiku.types.GenericMeta: + module = importlib.import_module(self.module, self.package) + cls = module.__dict__[self.classname] + + type_ref = hiku.types.TypeRef[cls.__hiku_node__.name] + + containers = reversed(self.containers or []) + for c in containers: + type_ref = c[type_ref] + + return type_ref + + def apply( + self, + container: hiku.types.OptionalMeta | hiku.types.SequenceMeta, + ) -> typing.Self: + return dc.replace( + self, + containers=[container] + (self.containers or []), + ) + + +class _HikuTypeWrapperProto(typing.Protocol): + + @property + def typ(self) -> hiku.types.GenericMeta: ... + + def apply( + self, container: hiku.types.OptionalMeta | hiku.types.SequenceMeta + ) -> typing.Self: ... + + +def to_hiku_type(typ: type, lazy_: lazy | None = None) -> _HikuTypeWrapperProto: + if typ in _BUILTINS_TO_HIKU: + return raw_type(_BUILTINS_TO_HIKU[typ]) + + origin = typing.get_origin(typ) + args = typing.get_args(typ) + + if origin is typing.Annotated: + metadata = typ.__metadata__ + + raw_types = [] + lazy_refs = [] + for val in metadata: + if isinstance(val, raw_type): + raw_types.append(val) + elif isinstance(val, lazy): + lazy_refs.append(val) + + if lazy_refs and raw_types: + raise ValueError("lazy and raw_type are not composable") + + if len(raw_types) > 1: + raise ValueError("more than 1 raw_type") + + if len(raw_types) == 1: + return raw_types[0] + + if len(lazy_refs) > 1: + raise ValueError("more than 1 lazy reference") + + if len(lazy_refs) == 1: + lazy_typeref = to_hiku_type(typ.__origin__, lazy_refs[0]) + if not isinstance(lazy_typeref, _LazyTypeRef): + raise ValueError("lazy can only be used with ref types") + + return lazy_typeref + + return to_hiku_type(args[0]) + + # new optionals + if origin in (typing.Union, types.UnionType): + if len(args) != 2 or types.NoneType not in args: + raise ValueError("unions are allowed only as optional types") + + next_type = [a for a in args if a is not types.NoneType][0] + arg = to_hiku_type(next_type, lazy_) + return arg.apply(hiku.types.Optional) + + # old optionals + if origin is typing.Optional: + arg = to_hiku_type(args[0], lazy_) + return arg.apply(hiku.types.Optional) + + # lists + if origin in (list, typing.List): + if len(args) == 0: + raise ValueError("naked lists not allowed") + + next_type = args[0] + arg = to_hiku_type(next_type, lazy_) + return arg.apply(hiku.types.Sequence) + + if origin is ref: + ref_ = args[0] + if isinstance(ref_, typing.ForwardRef): + if lazy_ is None: + raise ValueError("need to use hiku.lazy for lazy imports") + + return _LazyTypeRef( + classname=ref_.__forward_arg__, + module=lazy_.module, + package=lazy_.package, + ) + + if not hasattr(ref_, "__hiku_node__"): + raise ValueError("expected ref arg to be a @node") + + return raw_type(hiku.types.TypeRef[ref_.__hiku_node__.name]) + + raise ValueError("invalid hiku type") diff --git a/hiku/graph.py b/hiku/graph.py index 691da906..e8bcfd18 100644 --- a/hiku/graph.py +++ b/hiku/graph.py @@ -9,48 +9,41 @@ import dataclasses import typing as t - from abc import ABC, abstractmethod +from collections import OrderedDict, defaultdict from enum import Enum +from functools import cached_property, reduce from itertools import chain -from functools import reduce, cached_property -from collections import OrderedDict, defaultdict from typing import List from hiku.enum import BaseEnum -from .scalar import Scalar, ScalarMeta +from .compat import TypeAlias +from .directives import Deprecated, SchemaDirective +from .scalar import Scalar, ScalarMeta from .types import ( + Any, + AnyMeta, EnumRefMeta, + GenericMeta, InterfaceRef, InterfaceRefMeta, Optional, OptionalMeta, + Record, RefMeta, Sequence, SequenceMeta, TypeRef, - Record, - Any, - GenericMeta, TypeRefMeta, TypingMeta, - AnyMeta, UnionRef, UnionRefMeta, ) -from .utils import ( - const, - Const, -) -from .directives import Deprecated, SchemaDirective - -from .compat import TypeAlias - +from .utils import Const, const if t.TYPE_CHECKING: - from .sources.graph import SubGraph - from .sources.graph import BoundExpr + from .sources.graph import BoundExpr, SubGraph # TODO enum ??? Maybe = const("Maybe") @@ -669,7 +662,11 @@ class Node(AbstractNode): def __init__( self, name: t.Optional[str], - fields: t.List[t.Union[Field, Link]], + fields: t.Union[ + t.List[t.Union[Field, Link]], + t.List[Field], + t.List[Link], + ], *, description: t.Optional[str] = None, directives: t.Optional[t.Sequence[SchemaDirective]] = None, @@ -921,6 +918,22 @@ def visit_root(self, obj: Root) -> t.Any: def visit_graph(self, obj: Graph) -> t.Any: pass + # @abstractmethod + # def visit_node_cls(self, obj: ...) -> t.Any: + # pass + + # @abstractmethod + # def visit_field_cls(self, obj: ...) -> t.Any: + # pass + + # @abstractmethod + # def visit_field_link_cls(self, obj: ...) -> t.Any: + # pass + + # @abstractmethod + # def visit_option_cls(self, obj: ...) -> t.Any: + # pass + class GraphVisitor(AbstractGraphVisitor): def visit(self, obj: t.Any) -> t.Any: diff --git a/tests/classes/__init__.py b/tests/classes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/classes/droid.py b/tests/classes/droid.py new file mode 100644 index 00000000..a01e366c --- /dev/null +++ b/tests/classes/droid.py @@ -0,0 +1,8 @@ +from hiku.classes.node import node + + +@node(name="DroidNodeName") +class Droid: + __key__: int + + id: int diff --git a/tests/classes/test_types.py b/tests/classes/test_types.py new file mode 100644 index 00000000..b05e6748 --- /dev/null +++ b/tests/classes/test_types.py @@ -0,0 +1,291 @@ +import typing + +import pytest + +import hiku.types +from hiku.classes.node import node +from hiku.classes.types import _LazyTypeRef, lazy, raw_type, ref, to_hiku_type + +if typing.TYPE_CHECKING: + from tests.classes.droid import Droid + + +@pytest.mark.parametrize( + "typ,expected", + [ + # basic scalars + (int, raw_type(hiku.types.Integer)), + (float, raw_type(hiku.types.Float)), + (str, raw_type(hiku.types.String)), + (bool, raw_type(hiku.types.Boolean)), + # scalars in containers, old + new types + (list[int], raw_type(hiku.types.Sequence[hiku.types.Integer])), + (list[float], raw_type(hiku.types.Sequence[hiku.types.Float])), + (list[str], raw_type(hiku.types.Sequence[hiku.types.String])), + (list[bool], raw_type(hiku.types.Sequence[hiku.types.Boolean])), + (int | None, raw_type(hiku.types.Optional[hiku.types.Integer])), + (float | None, raw_type(hiku.types.Optional[hiku.types.Float])), + (str | None, raw_type(hiku.types.Optional[hiku.types.String])), + (bool | None, raw_type(hiku.types.Optional[hiku.types.Boolean])), + (typing.List[int], raw_type(hiku.types.Sequence[hiku.types.Integer])), + (typing.List[float], raw_type(hiku.types.Sequence[hiku.types.Float])), + (typing.List[str], raw_type(hiku.types.Sequence[hiku.types.String])), + (typing.List[bool], raw_type(hiku.types.Sequence[hiku.types.Boolean])), + ( + typing.Optional[int], + raw_type(hiku.types.Optional[hiku.types.Integer]), + ), + ( + typing.Optional[float], + raw_type(hiku.types.Optional[hiku.types.Float]), + ), + ( + typing.Optional[str], + raw_type(hiku.types.Optional[hiku.types.String]), + ), + ( + typing.Optional[bool], + raw_type(hiku.types.Optional[hiku.types.Boolean]), + ), + # some complex cases + ( + list[int | None], + raw_type( + hiku.types.Sequence[hiku.types.Optional[hiku.types.Integer]] + ), + ), + ( + typing.List[typing.Optional[int]], + raw_type( + hiku.types.Sequence[hiku.types.Optional[hiku.types.Integer]] + ), + ), + ( + list[str] | None, + raw_type( + hiku.types.Optional[hiku.types.Sequence[hiku.types.String]] + ), + ), + ( + typing.Optional[typing.List[str]], + raw_type( + hiku.types.Optional[hiku.types.Sequence[hiku.types.String]] + ), + ), + # some absurd stuff just for the sake of it + ( + list[list[bool | None]] | None, + raw_type( + hiku.types.Optional[ + hiku.types.Sequence[ + hiku.types.Sequence[ + hiku.types.Optional[hiku.types.Boolean] + ] + ] + ] + ), + ), + ( + typing.Optional[typing.List[typing.List[typing.Optional[bool]]]], + raw_type( + hiku.types.Optional[ + hiku.types.Sequence[ + hiku.types.Sequence[ + hiku.types.Optional[hiku.types.Boolean] + ] + ] + ] + ), + ), + ], +) +def test_to_hiku_type__scalars(typ, expected): + assert to_hiku_type(typ) == expected + + +@pytest.mark.parametrize( + "typ", + [ + complex, + tuple, + typing.Any, + None, + 1, + list, + typing.Optional, + int | str, + typing.Union[bool, float], + typing.Annotated[int, lazy(".")], + ref["Droid"], + ], +) +def test_to_hiku_type__raises(typ): + with pytest.raises(ValueError): + to_hiku_type(typ) + + +@node(name="HumanNodeName") +class Human: + __key__: int + + id: int + + +@pytest.mark.parametrize( + "typ,expected", + [ + (ref[Human], raw_type(hiku.types.TypeRef["HumanNodeName"])), + ( + ref[Human] | None, + raw_type(hiku.types.Optional[hiku.types.TypeRef["HumanNodeName"]]), + ), + ( + typing.Optional[ref[Human]], + raw_type(hiku.types.Optional[hiku.types.TypeRef["HumanNodeName"]]), + ), + ( + list[ref[Human]], + raw_type(hiku.types.Sequence[hiku.types.TypeRef["HumanNodeName"]]), + ), + ( + typing.List[ref[Human]], + raw_type(hiku.types.Sequence[hiku.types.TypeRef["HumanNodeName"]]), + ), + ], +) +def test_to_hiku_type__ref(typ, expected): + assert to_hiku_type(typ) == expected + + +@pytest.mark.parametrize( + "typ,expected", + [ + ( + typing.Annotated[ref["Droid"], lazy(".droid")], + _LazyTypeRef( + classname="Droid", + module=".droid", + package="tests.classes", + ), + ), + ( + typing.Annotated[ref["Droid"], lazy("tests.classes.droid")], + _LazyTypeRef( + classname="Droid", + module="tests.classes.droid", + package=None, + ), + ), + ( + typing.Annotated[ref["Droid"] | None, lazy("tests.classes.droid")], + _LazyTypeRef( + classname="Droid", + module="tests.classes.droid", + package=None, + containers=[hiku.types.Optional], + ), + ), + ( + typing.Optional[ + typing.Annotated[ref["Droid"], lazy("tests.classes.droid")] + ], + _LazyTypeRef( + classname="Droid", + module="tests.classes.droid", + package=None, + containers=[hiku.types.Optional], + ), + ), + ( + typing.Annotated[list[ref["Droid"]], lazy("tests.classes.droid")], + _LazyTypeRef( + classname="Droid", + module="tests.classes.droid", + package=None, + containers=[hiku.types.Sequence], + ), + ), + ( + typing.Annotated[ + typing.List[ref["Droid"]], lazy("tests.classes.droid") + ], + _LazyTypeRef( + classname="Droid", + module="tests.classes.droid", + package=None, + containers=[hiku.types.Sequence], + ), + ), + ( + typing.Annotated[ + list[list[ref["Droid"] | None]] | None, + lazy("tests.classes.droid"), + ], + _LazyTypeRef( + classname="Droid", + module="tests.classes.droid", + package=None, + containers=[ + hiku.types.Optional, + hiku.types.Sequence, + hiku.types.Sequence, + hiku.types.Optional, + ], + ), + ), + ], +) +def test_to_hiku_type__lazy_ref(typ, expected): + assert to_hiku_type(typ) == expected + + +@pytest.mark.parametrize( + "typ,expected", + [ + ( + typing.Annotated[typing.Any, raw_type(hiku.types.Any)], + raw_type(hiku.types.Any), + ), + ( + typing.Annotated[str, raw_type(hiku.types.ID)], + raw_type(hiku.types.ID), + ), + ( + typing.Annotated[ + list[float | None] | None, + raw_type( + hiku.types.Optional[ + hiku.types.Sequence[ + hiku.types.Optional[hiku.types.Float] + ] + ] + ), + ], + raw_type( + hiku.types.Optional[ + hiku.types.Sequence[hiku.types.Optional[hiku.types.Float]] + ] + ), + ), + ( + typing.Annotated[None, raw_type(hiku.types.TypeRef["SomeNode"])], + raw_type(hiku.types.TypeRef["SomeNode"]), + ), + ( + typing.Annotated[None, raw_type(hiku.types.UnionRef["SomeUnion"])], + raw_type(hiku.types.UnionRef["SomeUnion"]), + ), + ( + typing.Annotated[ + None, raw_type(hiku.types.InterfaceRef["SomeIface"]) + ], + raw_type(hiku.types.InterfaceRef["SomeIface"]), + ), + ( + typing.Annotated[None, raw_type(hiku.types.EnumRef["SomeEnum"])], + raw_type(hiku.types.EnumRef["SomeEnum"]), + ), + ], +) +def test_to_hiku_type__raw_type(typ, expected): + assert to_hiku_type(typ) == expected