Skip to content

Commit

Permalink
Merge pull request #6 from tarsil/feature/fields
Browse files Browse the repository at this point in the history
[Feature] - PolyField
  • Loading branch information
tarsil authored Oct 14, 2023
2 parents 5e74d98 + c3c9898 commit f05b559
Show file tree
Hide file tree
Showing 14 changed files with 754 additions and 39 deletions.
12 changes: 11 additions & 1 deletion polyforce/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
__version__ = "0.2.0"

from .config import Config
from .core import PolyforceUndefinedType
from .decorator import polycheck
from .fields import Field, PolyField
from .main import PolyModel

__all__ = ["Config", "polycheck", "PolyModel"]
__all__ = [
"Config",
"PolyforceUndefined",
"PolyforceUndefinedType",
"polycheck",
"PolyField",
"PolyModel",
"Field",
]
82 changes: 65 additions & 17 deletions polyforce/_internal/_construction.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@
cast,
)

from typing_extensions import get_args

from polyforce.exceptions import MissingAnnotation, ReturnSignatureMissing, ValidationError

from ..constants import SPECIAL_CHECK
from ..constants import INIT_FUNCTION, SPECIAL_CHECK
from ..core._polyforce_core import PolyforceUndefined
from ..decorator import polycheck
from ..fields import PolyField
from ._config import ConfigWrapper
from ._errors import ErrorDetail
from ._serializer import json_serializable
Expand Down Expand Up @@ -76,7 +76,10 @@ def __new__(
model.__polymodel_custom_init__ = not getattr(
model.__init__, "__polymodel_base_init__", False
)
complete_poly_class(model, config_wrapper)
# Making sure the PolyFields are only from this class object.
model.poly_fields = {}
model.__signature__ = {}
complete_poly_class(model, bases, config_wrapper)
return model
return cast("Type[PolyModel]", super().__new__(cls, name, bases, attrs))

Expand Down Expand Up @@ -133,7 +136,7 @@ def __getattribute__(self, name: str) -> Any:
except (KeyError, AttributeError):
return object.__getattribute__(self, name)

def _extract_type_hint(self, type_hint: Union[Type, tuple]) -> Union[Type, tuple]:
def _extract_type_hint(self, type_hint: Union[Type, tuple]) -> Any:
"""
Extracts the base type from a type hint, considering typing extensions.
Expand All @@ -157,11 +160,15 @@ def _extract_type_hint(self, type_hint: Union[Type, tuple]) -> Union[Type, tuple
original_hint = extract_type_hint(Union[int, str]) # Returns Union[int, str]
```
"""
if hasattr(type_hint, "__origin__"):
return get_args(type_hint)
return type_hint

def _add_static_type_checking(self, func: Any, signature: Signature) -> Callable:
origin = getattr(type_hint, "__origin__", type_hint)
if isinstance(origin, _SpecialForm):
origin = type_hint.__args__ # type: ignore
return origin

def _add_static_type_checking(
self: Type["PolyModel"], func: Any, signature: Signature
) -> Callable:
"""
Add static type checking to a method or function.
Expand Down Expand Up @@ -191,9 +198,9 @@ def polycheck(*args: Any, **kwargs: Any) -> Any:
bound.apply_defaults()

for name, value in bound.arguments.items():
nonlocal self
if name in signature.parameters:
expected_type = signature.parameters[name].annotation
if name in self.poly_fields[func.__name__]:
field: PolyField = self.poly_fields[func.__name__][name]
expected_type = field.annotation

if expected_type in (_SpecialForm, Any):
continue
Expand Down Expand Up @@ -228,7 +235,7 @@ def polycheck(*args: Any, **kwargs: Any) -> Any:
return polycheck


def complete_poly_class(cls: Type["PolyModel"], config: ConfigWrapper) -> bool:
def complete_poly_class(cls: Type["PolyModel"], bases: Tuple[Type], config: ConfigWrapper) -> bool:
"""
Completes the polyclass model construction and applies all the fields and configurations.
Expand All @@ -249,18 +256,34 @@ def complete_poly_class(cls: Type["PolyModel"], config: ConfigWrapper) -> bool:
and inspect.isroutine(getattr(cls, attr))
]

if cls.__polymodel_custom_init__:
methods.append("__init__")
for base in bases:
if hasattr(base, "__signature__"):
cls.__signature__.update(base.__signature__)

if INIT_FUNCTION in cls.__dict__ or (
INIT_FUNCTION not in cls.__dict__ and INIT_FUNCTION not in cls.__signature__
):
methods.append(INIT_FUNCTION)

signatures: Dict[str, Signature] = {}

for method in methods:
signatures[method] = generate_model_signature(cls, method, config)

cls.__signature__ = signatures # type: ignore[misc]
cls.__signature__.update(signatures)

if cls.__polymodel_custom_init__:
# Special decorator for the __init__ since it is not manipulated by the
# __getattribute__ functionality
if INIT_FUNCTION in cls.__dict__ or (
INIT_FUNCTION not in cls.__dict__ and INIT_FUNCTION not in cls.__signature__
):
decorate_function(cls, config)

# Generate the PolyFields
for value, signature in cls.__signature__.items():
for param in signature.parameters.values():
# Generate the PolyField for each function.
generate_polyfields(cls, value, param)
return True


Expand Down Expand Up @@ -297,6 +320,31 @@ def ignore_signature(signature: Signature) -> Signature:
return Signature(parameters=list(merged_params.values()), return_annotation=Any)


def generate_polyfields(
cls: Type["PolyModel"], method: str, parameter: Parameter
) -> Dict[str, Dict[str, PolyField]]:
"""
For all the fields found in the signature, it will generate
PolyField type variable.
"""
data = {
"annotation": parameter.annotation,
"name": parameter.name,
"default": PolyforceUndefined
if parameter.default == Signature.empty
else parameter.default,
}

field = PolyField(**data)
field_data = {field.name: field}

if method not in cls.poly_fields:
cls.poly_fields[method] = {}

cls.poly_fields[method].update(field_data)
return cls.poly_fields


def generate_model_signature(
cls: Type["PolyModel"], value: str, config: ConfigWrapper
) -> Signature:
Expand Down
171 changes: 171 additions & 0 deletions polyforce/_internal/_representation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""
The MIT License (MIT)
Copyright (c) 2017 to present Pydantic Services Inc. and individual contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This file contains the object representation used by Pydantic with minor
changes.
"""
from __future__ import annotations as _annotations

import sys
import types
import typing
from typing import Any, Type

import typing_extensions

try:
from typing import _TypingBase # type: ignore[attr-defined]
except ImportError:
from typing import _Final as _TypingBase # type: ignore[attr-defined]

if typing.TYPE_CHECKING:
ReprArgs: typing_extensions.TypeAlias = "typing.Iterable[tuple[str | None, Any]]"
RichReprResult: typing_extensions.TypeAlias = (
"typing.Iterable[Any | tuple[Any] | tuple[str, Any] | tuple[str, Any, Any]]"
)

if sys.version_info < (3, 9):
# python < 3.9 does not have GenericAlias (list[int], tuple[str, ...] and so on)
TypingGenericAlias = ()
else:
from typing import GenericAlias as TypingGenericAlias # type: ignore


if sys.version_info < (3, 11):
from typing_extensions import NotRequired, Required
else:
from typing import NotRequired, Required # noqa: F401


if sys.version_info < (3, 10):

def origin_is_union(tp: Type[Any] | None) -> bool:
return tp is typing.Union

WithArgsTypes = (TypingGenericAlias,)

else:

def origin_is_union(tp: Type[Any] | None) -> bool:
return tp is typing.Union or tp is types.UnionType

WithArgsTypes = typing._GenericAlias, types.GenericAlias, types.UnionType # type: ignore[attr-defined]


if sys.version_info < (3, 10):
NoneType = type(None)
EllipsisType = type(Ellipsis)
else:
pass


class PlainRepresentation(str):
"""String class where repr doesn't include quotes. Useful with Representation when you want to return a string representation of something that is valid (or pseudo-valid) python."""

def __repr__(self) -> str:
return str(self)


class Representation:
"""
Misin that provides representation of everything
Polyforce.
"""

__slots__ = () # type: typing.Collection[str]

def __repr_args__(self) -> ReprArgs:
"""Returns the attributes to show in __str__, __repr__, and __pretty__ this is generally overridden."""
attrs_names = self.__slots__
if not attrs_names and hasattr(self, "__dict__"):
attrs_names = self.__dict__.keys()
attrs = ((s, getattr(self, s)) for s in attrs_names)
return [(a, v) for a, v in attrs if v is not None]

def __repr_name__(self) -> str:
"""Name of the instance's class, used in __repr__."""
return self.__class__.__name__

def __repr_str__(self, join_str: str) -> str:
return join_str.join(
repr(v) if a is None else f"{a}={v!r}" for a, v in self.__repr_args__()
)

def __pretty__(
self, fmt: typing.Callable[[Any], Any], **kwargs: Any
) -> typing.Generator[Any, None, None]:
"""Used by devtools (https://python-devtools.helpmanual.io/) to pretty print objects."""
yield self.__repr_name__() + "("
yield 1
for name, value in self.__repr_args__():
if name is not None:
yield name + "="
yield fmt(value)
yield ","
yield 0
yield -1
yield ")"

def __rich_repr__(self) -> RichReprResult:
"""Used by Rich (https://rich.readthedocs.io/en/stable/pretty.html) to pretty print objects."""
for name, field_repr in self.__repr_args__():
if name is None:
yield field_repr
else:
yield name, field_repr

def __str__(self) -> str:
return self.__repr_str__(" ")

def __repr__(self) -> str:
return f'{self.__repr_name__()}({self.__repr_str__(", ")})'


def display_as_type(obj: Any) -> str:
"""Pretty representation of a type, should be as close as possible to the original type definition string.
Takes some logic from `typing._type_repr`.
"""
if isinstance(obj, types.FunctionType):
return obj.__name__
elif obj is ...:
return "..."
elif isinstance(obj, Representation):
return repr(obj)

if not isinstance(obj, (_TypingBase, WithArgsTypes, type)):
obj = obj.__class__ # type: ignore

if origin_is_union(typing_extensions.get_origin(obj)):
args = ", ".join(map(display_as_type, typing_extensions.get_args(obj)))
return f"Union[{args}]"
elif isinstance(obj, WithArgsTypes):
if typing_extensions.get_origin(obj) == typing_extensions.Literal:
args = ", ".join(map(repr, typing_extensions.get_args(obj)))
else:
args = ", ".join(map(display_as_type, typing_extensions.get_args(obj)))
return f"{obj.__qualname__}[{args}]"
elif isinstance(obj, type): # type: ignore[unreachable,unused-ignore]
return obj.__qualname__
else:
return repr(obj).replace("typing.", "").replace("typing_extensions.", "")
1 change: 1 addition & 0 deletions polyforce/constants.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
CLASS_SPECIAL_WORDS = {"self", "cls"}
SPECIAL_CHECK = {"__init__"}
INIT_FUNCTION = "__init__"
3 changes: 3 additions & 0 deletions polyforce/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from ._polyforce_core import PolyforceUndefined, PolyforceUndefinedType

__all__ = ["PolyforceUndefinedType", "PolyforceUndefined"]
15 changes: 15 additions & 0 deletions polyforce/core/_polyforce_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from typing import Any, final

from typing_extensions import Self


@final
class PolyforceUndefinedType:
def __copy__(self) -> Self:
...

def __deepcopy__(self, memo: Any) -> Self:
...


PolyforceUndefined = PolyforceUndefinedType
19 changes: 19 additions & 0 deletions polyforce/core/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import Any

from typing_extensions import Annotated, get_origin

from .._internal._representation import WithArgsTypes


def is_annotated(ann_type: Any) -> bool:
origin = get_origin(ann_type)
return origin is not None and lenient_issubclass(origin, Annotated)


def lenient_issubclass(cls: Any, class_or_tuple: Any) -> bool: # pragma: no cover
try:
return isinstance(cls, type) and issubclass(cls, class_or_tuple)
except TypeError:
if isinstance(cls, WithArgsTypes):
return False
raise # pragma: no cover
Loading

0 comments on commit f05b559

Please sign in to comment.