Skip to content

Commit

Permalink
Type variables bound by forward references.
Browse files Browse the repository at this point in the history
This commit adds explicit support for PEP 484-compliant type variables
bound by forward references (e.g., type hints of the form
`TypeVar('{TypeVarName}', bounds='{UndefinedType}')`), fully resolving
issue #367 kindly submitted by Dark Typing Priestess of Darkness
@iamrecursion (Ara Adkins). Previously, @beartype only partially
supported such variables due to @leycec failing to realize that such
variables even existed and constituted a valid use case. Now, @beartype
explicitly supports such variables via a newly refactored error-handling
backend and unit tests exercising this edge case. (*Undeniably unduly undulations!*)
  • Loading branch information
leycec committed Apr 17, 2024
1 parent d473c1a commit 8727751
Show file tree
Hide file tree
Showing 8 changed files with 345 additions and 272 deletions.
110 changes: 67 additions & 43 deletions beartype/_check/error/_errorcause.py
Expand Up @@ -67,8 +67,8 @@
is_hint_pep,
is_hint_pep_args,
)
from beartype._check.convert.convsanify import sanify_hint_child
from beartype._util.hint.utilhinttest import is_hint_ignorable
from beartype._check.convert.convsanify import (
sanify_hint_child_if_unignorable_or_none)

# ....................{ CLASSES }....................
class ViolationCause(object):
Expand Down Expand Up @@ -108,6 +108,8 @@ class variable or method annotated by this hint *or* :data:`None`).
* If this violation originates from a decorated callable, that
callable.
* Else, :data:`None`.
hint : Any
Type hint to validate this object against.
hint_sign : Any
Either:
Expand All @@ -116,9 +118,8 @@ class variable or method annotated by this hint *or* :data:`None`).
hint_childs : Optional[Tuple]
Either:
* If this hint is PEP-compliant, the possibly empty tuple of all
arguments subscripting (indexing) this hint.
* If this hint is PEP-compliant, the possibly empty tuple of all child
type hints subscripting (indexing) this hint.
* Else, :data:`None`.
pith : Any
Arbitrary object to be validated.
Expand All @@ -139,8 +140,6 @@ class variable or method annotated by this hint *or* :data:`None`).
such integer). See the same parameter accepted by the higher-level
:func:`beartype._check.error.errorget.get_func_pith_violation`
function for further details.
_hint : Any
Type hint to validate this object against.
'''

# ..................{ CLASS VARIABLES }..................
Expand All @@ -154,12 +153,12 @@ class variable or method annotated by this hint *or* :data:`None`).
'conf',
'exception_prefix',
'func',
'hint',
'hint_sign',
'hint_childs',
'pith',
'pith_name',
'random_int',
'_hint',
)


Expand Down Expand Up @@ -230,6 +229,7 @@ def __init__(
self.func = func
self.cls_stack = cls_stack
self.conf = conf
# self.hint = hint
self.pith = pith
self.pith_name = pith_name
self.cause_indent = cause_indent
Expand All @@ -241,51 +241,75 @@ def __init__(
self.hint_sign: Any = None
self.hint_childs: Tuple = None # type: ignore[assignment]

# Classify this hint *AFTER* initializing all parameters above.
# Unignorable sane hint sanified from this possibly ignorable insane
# hint *OR* "None" otherwise (i.e., if this hint is ignorable).
#
# Note that this assignment magically calls the "hint" property setter
# defined below. Don't ask. Just do it, Python! *sigh*
self.hint = hint

# ..................{ PROPERTIES }..................
@property
def hint(self) -> Any:
'''
Type hint to validate this object against.
'''

return self._hint


@hint.setter
def hint(self, hint: Any) -> None:
'''
Set the type hint to validate this object against.
'''

# Sanitize this hint if unsupported by @beartype in its current form
# (e.g., "numpy.typing.NDArray[...]") to another form supported by
# @beartype (e.g., "typing.Annotated[numpy.ndarray, beartype.vale.*]").
hint = sanify_hint_child(
# Note that this is a bit inefficient. Since child hints are already
# sanitized below, the sanitization performed by this assignment
# effectively reduces to a noop for all type hints *EXCEPT* the root
# type hint. Technically, this means this could be marginally optimized
# by externally sanitizing the root type hint in the "errorget"
# submodule. Pragmatically, doing so would only complicate an already
# overly complex workflow for little to no tangible gain.
self.hint = sanify_hint_child_if_unignorable_or_none(
hint=hint,
conf=self.conf,
cls_stack=self.cls_stack,
pith_name=self.pith_name,
exception_prefix=self.exception_prefix,
)

# If this hint is PEP-compliant...
if is_hint_pep(hint):
# If this hint is both...
if (
# Unignorable *AND*...
self.hint is not None and
# PEP-compliant...
is_hint_pep(self.hint)
):
# Arbitrary object uniquely identifying this hint.
self.hint_sign = get_hint_pep_sign(hint)
self.hint_sign = get_hint_pep_sign(self.hint)

# Tuple of the zero or more arguments subscripting this hint.
self.hint_childs = get_hint_pep_args(hint)
hint_childs_insane = get_hint_pep_args(self.hint)

# List of the zero or more possibly ignorable sane child hints
# subscripting this parent hint, initialized to the empty list.
hint_childs_sane = []

# For each possibly ignorable insane child hints subscripting this
# parent hint...
for hint_child_insane in hint_childs_insane:
# If this child hint is PEP-compliant...
#
# Note that arbitrary PEP-noncompliant arguments *CANNOT* be
# safely sanitized. Why? Because arbitrary arguments are *NOT*
# necessarily valid type hints. Consider the type hint
# "tuple[()]", where the argument "()" is invalid as a type hint
# but valid an argument to that type hint.
if is_hint_pep(hint_child_insane):
# Unignorable sane child hint sanified from this possibly
# ignorable insane child hint *OR* "None" otherwise (i.e.,
# if this child hint is ignorable).
hint_child_sane = sanify_hint_child_if_unignorable_or_none(
hint=hint_child_insane,
conf=self.conf,
cls_stack=self.cls_stack,
pith_name=self.pith_name,
exception_prefix=self.exception_prefix,
)
# Else, this child hint is PEP-noncompliant. In this case,
# preserve this child hint as is.
else:
hint_child_sane = hint_child_insane

# Append this possibly ignorable sane child hint to this list.
hint_childs_sane.append(hint_child_sane)

# Tuple of the zero or more possibly ignorable sane child hints
# subscripting this parent hint, coerced from this list.
self.hint_childs = tuple(hint_childs_sane)
# Else, this hint is PEP-noncompliant (e.g., isinstanceable class).

# Classify this hint *AFTER* all other assignments above.
self._hint = hint

# ..................{ GETTERS }..................
def find_cause(self) -> 'ViolationCause':
'''
Expand All @@ -309,7 +333,7 @@ def find_cause(self) -> 'ViolationCause':
For example, consider the PEP-compliant type hint ``List[Union[int,
str]]`` describing a list whose items are either integers or strings
and the list ``list(range(256)) + [False,]`` consisting of the integers
0 through 255 followed by boolean ``False``. Since that list is a
0 through 255 followed by boolean :data:`False`. Since that list is a
standard sequence, the
:func:`._peperrorsequence.find_cause_sequence_args_1`
function must decide the cause of this list's failure to comply with
Expand All @@ -318,7 +342,7 @@ def find_cause(self) -> 'ViolationCause':
:func:`._peperrorunion.find_cause_union` function. Since
the first 256 items of this list are integers satisfying this hint,
:func:`._peperrorunion.find_cause_union` returns a dataclass instance
whose :attr:`cause` field is ``None`` up to
whose :attr:`cause` field is :data:`None` up to
:func:`._peperrorsequence.find_cause_sequence_args_1`
before finally finding the non-compliant boolean item and returning the
human-readable cause.
Expand All @@ -341,7 +365,7 @@ def find_cause(self) -> 'ViolationCause':
# If this hint is ignorable, all possible objects satisfy this hint.
# Since this hint *CANNOT* (by definition) be the cause of this failure,
# return the same cause as is.
if is_hint_ignorable(self.hint):
if self.hint is None:
return self
# Else, this hint is unignorable.

Expand Down
3 changes: 1 addition & 2 deletions beartype/_check/error/_pep/errorpep484604union.py
Expand Up @@ -18,7 +18,6 @@
from beartype._util.hint.pep.utilpepget import (
get_hint_pep_origin_type_isinstanceable_or_none)
from beartype._util.hint.pep.utilpeptest import is_hint_pep
from beartype._util.hint.utilhinttest import is_hint_ignorable
from beartype._util.text.utiltextansi import color_hint
from beartype._util.text.utiltextjoin import join_delimited_disjunction_types
from beartype._util.text.utiltextmunge import (
Expand Down Expand Up @@ -72,7 +71,7 @@ def find_cause_union(cause: ViolationCause) -> ViolationCause:
# For each subscripted argument of this union...
for hint_child in cause.hint_childs:
# If this child hint is ignorable, continue to the next.
if is_hint_ignorable(hint_child):
if hint_child is None:
continue
# Else, this child hint is unignorable.

Expand Down
169 changes: 85 additions & 84 deletions beartype/_check/error/_pep/pep484585/errormapping.py
Expand Up @@ -21,7 +21,6 @@
from beartype._data.hint.pep.sign.datapepsignset import HINT_SIGNS_MAPPING
from beartype._check.error._errorcause import ViolationCause
from beartype._check.error._errortype import find_cause_type_instance_origin
from beartype._util.hint.utilhinttest import is_hint_ignorable
from beartype._util.text.utiltextprefix import prefix_pith_type
from beartype._util.text.utiltextrepr import represent_pith

Expand Down Expand Up @@ -65,89 +64,91 @@ def find_cause_mapping(cause: ViolationCause) -> ViolationCause:
return cause_shallow
# Else, this pith is an instance of this type and is thus a mapping.
#
# If this mapping is non-empty...
elif cause.pith:
# Child key and value hints subscripting this mapping hint.
hint_key = cause.hint_childs[0]
hint_value = cause.hint_childs[1]

# True only if these hints are unignorable.
hint_key_unignorable = not is_hint_ignorable(hint_key)
hint_value_unignorable = not is_hint_ignorable(hint_value)

# Arbitrary iterator vaguely satisfying the dict.items() protocol,
# yielding zero or more 2-tuples of the form "(key, value)", where:
# * "key" is the key of the current key-value pair.
# * "value" is the value of the current key-value pair.
pith_items: Iterable[Tuple[Hashable, object]] = None # type: ignore[assignment]

# If the only the first key-value pair of this mapping was
# type-checked by the the parent @beartype-generated wrapper
# function in O(1) time, type-check only this key-value pair of this
# mapping in O(1) time as well.
if cause.conf.strategy is BeartypeStrategy.O1:
# First key-value pair of this mapping.
pith_item = next(iter(cause.pith.items()))

# Tuple containing only this pair.
pith_items = (pith_item,)
# print(f'Checking item {pith_item_index} in O(1) time!')
# Else, all keys of this mapping were type-checked by the parent
# @beartype-generated wrapper function in O(n) time. In this case,
# type-check *ALL* indices of this mapping in O(n) time as well.
else:
# Iterator yielding all key-value pairs of this mapping.
pith_items = cause.pith.items()
# print('Checking mapping in O(n) time!')

# For each key-value pair of this mapping...
for pith_key, pith_value in pith_items:
# If this child key hint is unignorable...
if hint_key_unignorable:
# Deep output cause, type-checking whether this key satisfies
# this child key hint.
cause_deep = cause.permute(
pith=pith_key, hint=hint_key).find_cause()

# If this key is the cause of this failure...
if cause_deep.cause_str_or_none is not None:
# Human-readable substring prefixing this failure with
# metadata describing this key.
cause_deep.cause_str_or_none = (
f'{prefix_pith_type(pith=cause.pith, is_color=True)}'
f'key {cause_deep.cause_str_or_none}'
)

# Return this cause.
return cause_deep
# Else, this key is *NOT* the cause of this failure. Silently
# continue to this value.
# Else, this child key hint is ignorable.

# If this child value hint is unignorable...
if hint_value_unignorable:
# Deep output cause, type-checking whether this value satisfies
# this child value hint.
cause_deep = cause.permute(
pith=pith_value, hint=hint_value).find_cause()

# If this value is the cause of this failure...
if cause_deep.cause_str_or_none is not None:
# Human-readable substring prefixing this failure with
# metadata describing this value.
cause_deep.cause_str_or_none = (
f'{prefix_pith_type(pith=cause.pith, is_color=True)}'
f'key {represent_pith(pith_key)} '
f'value {cause_deep.cause_str_or_none}'
)

# Return this cause.
return cause_deep
# Else, this value is *NOT* the cause of this failure. Silently
# continue to the key-value pair.
# Else, this child value hint is ignorable.
# Else, this mapping is empty, in which case all items of this mapping (of
# which there are none) are valid. Just go with it, people.
# If this mapping is empty, all items of this mapping (of which there are
# none) are valid. This mapping satisfies this hint. Just go with it!
elif not cause.pith:
return cause
# Else, this mapping is non-empty.

# Child key and value hints subscripting this mapping hint.
hint_key = cause.hint_childs[0]
hint_value = cause.hint_childs[1]

# True only if these hints are unignorable.
hint_key_unignorable = hint_key is not None
hint_value_unignorable = hint_value is not None

# Arbitrary iterator vaguely satisfying the dict.items() protocol,
# yielding zero or more 2-tuples of the form "(key, value)", where:
# * "key" is the key of the current key-value pair.
# * "value" is the value of the current key-value pair.
pith_items: Iterable[Tuple[Hashable, object]] = None # type: ignore[assignment]

# If the only the first key-value pair of this mapping was
# type-checked by the the parent @beartype-generated wrapper
# function in O(1) time, type-check only this key-value pair of this
# mapping in O(1) time as well.
if cause.conf.strategy is BeartypeStrategy.O1:
# First key-value pair of this mapping.
pith_item = next(iter(cause.pith.items()))

# Tuple containing only this pair.
pith_items = (pith_item,)
# print(f'Checking item {pith_item_index} in O(1) time!')
# Else, all keys of this mapping were type-checked by the parent
# @beartype-generated wrapper function in O(n) time. In this case,
# type-check *ALL* indices of this mapping in O(n) time as well.
else:
# Iterator yielding all key-value pairs of this mapping.
pith_items = cause.pith.items()
# print('Checking mapping in O(n) time!')

# For each key-value pair of this mapping...
for pith_key, pith_value in pith_items:
# If this child key hint is unignorable...
if hint_key_unignorable:
# Deep output cause, type-checking whether this key satisfies
# this child key hint.
cause_deep = cause.permute(
pith=pith_key, hint=hint_key).find_cause()

# If this key is the cause of this failure...
if cause_deep.cause_str_or_none is not None:
# Human-readable substring prefixing this failure with
# metadata describing this key.
cause_deep.cause_str_or_none = (
f'{prefix_pith_type(pith=cause.pith, is_color=True)}'
f'key {cause_deep.cause_str_or_none}'
)

# Return this cause.
return cause_deep
# Else, this key is *NOT* the cause of this failure. Silently
# continue to this value.
# Else, this child key hint is ignorable.

# If this child value hint is unignorable...
if hint_value_unignorable:
# Deep output cause, type-checking whether this value satisfies
# this child value hint.
cause_deep = cause.permute(
pith=pith_value, hint=hint_value).find_cause()

# If this value is the cause of this failure...
if cause_deep.cause_str_or_none is not None:
# Human-readable substring prefixing this failure with
# metadata describing this value.
cause_deep.cause_str_or_none = (
f'{prefix_pith_type(pith=cause.pith, is_color=True)}'
f'key {represent_pith(pith_key)} '
f'value {cause_deep.cause_str_or_none}'
)

# Return this cause.
return cause_deep
# Else, this value is *NOT* the cause of this failure. Silently
# continue to the key-value pair.
# Else, this child value hint is ignorable.

# Return this cause as is; all items of this mapping are valid, implying
# this mapping to deeply satisfy this hint.
Expand Down

0 comments on commit 8727751

Please sign in to comment.