Skip to content

Commit

Permalink
Parameter default type-checking x 4.
Browse files Browse the repository at this point in the history
This commit is the last in a commit chain type-checking the default
values of optional parameters accepted by `@beartype`-decorated
callables, resolving feature request #154 kindly submitted by @dosisod
(Logan Hunt) a literal lifetime ago when @leycec still had hair. And
what kind of an unabashedly superheroic name is "Logan Hunt," anyway? He
was destined for greatness. Specifically, this commit:

* Defines two new exception and warning types:
  * `beartype.roar.BeartypeDecorHintParamDefaultViolation`, raised at
    `@beartype` decoration time when the default value of an optional
    parameter violates its type hint.
  * `beartype.roar.BeartypeDecorHintParamDefaultForwardRefWarning`,
    emitted when the type hint annotating an optional parameter contains
    one or more **unresolvable forward references** (i.e., references to
    user-defined objects that have yet to be defined at `@beartype`
    decoration time).
* Raises the new `beartype.roar.BeartypeDecorHintParamDefaultViolation`
  exception type at `@beartype` decoration time when the default value
  of an optional parameter violates its type hint.
* Emits the new
  `beartype.roar.BeartypeDecorHintParamDefaultForwardRefWarning` warning
  category when the type hint annotating an optional parameter contains
  one or more unresolvable forward references.
* Exhaustively tests this functionality.

I am exhausted so that you don't have to be.
(*Contrariwise to unwise disestablishmentarian contrarianism!*)
  • Loading branch information
leycec committed Mar 27, 2024
1 parent 3926b09 commit 0fccdb4
Show file tree
Hide file tree
Showing 19 changed files with 374 additions and 84 deletions.
31 changes: 16 additions & 15 deletions beartype/_check/checkmake.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,34 +83,35 @@ def make_func_raiser(
# rather than by keyword. Care should be taken when refactoring parameters,
# particularly with respect to parameter position.
#!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

# Mandatory parameters.
hint: object,

# Optional parameters.
conf: BeartypeConf = BEARTYPE_CONF_DEFAULT,
exception_prefix: str = 'die_if_unbearable() ',
conf: BeartypeConf,
exception_prefix: str,
) -> CallableRaiser:
'''
**Type-checking raiser function factory** (i.e., low-level callable
dynamically generating a pure-Python raiser function testing whether an
arbitrary object passed to that tester satisfies the type hint passed to
arbitrary object passed to that raiser satisfies the type hint passed to
this factory and either raising an exception or emitting a warning when that
object violates that hint).
This factory is memoized for efficiency.
Caveats
-------
**This factory intentionally accepts no** ``exception_cls`` **parameter.**
Instead, simply set the :attr:`.BeartypeConf.violation_door_type` option of
the passed ``conf`` parameter accordingly.
Parameters
----------
hint : object
Type hint to be type-checked.
conf : BeartypeConf, optional
conf : BeartypeConf
**Beartype configuration** (i.e., self-caching dataclass encapsulating
all settings configuring type-checking for the passed object). Defaults
to ``BeartypeConf()``, the default :math:`O(1)` configuration.
exception_prefix : str, optional
all settings configuring type-checking for the passed object).
exception_prefix : str
Human-readable label prefixing the representation of this object in the
exception message. Defaults to a reasonably sensible string.
exception message.
Returns
-------
Expand Down Expand Up @@ -596,8 +597,8 @@ def _make_func_checker(
portability of calls by users to the resulting type-checker.
_BeartypeUtilCallableException
If this function erroneously generates a syntactically invalid
type-checking tester function. That should *never* happen, but let's
admit that you're still reading this for a reason.
type-checking function. That should *never* happen, but let's admit that
you're still reading this for a reason.
Warns
-----
Expand All @@ -608,7 +609,7 @@ def _make_func_checker(
# Attempt to...
try:
# With a context manager "catching" *ALL* non-fatal warnings emitted
# during this logic for subsequent "playrback" below...
# during this logic for subsequent "playback" below...
with catch_warnings(record=True) as warnings_issued:
# ....................{ VALIDATION }....................
# If "conf" is *NOT* a configuration, raise an exception.
Expand Down
6 changes: 3 additions & 3 deletions beartype/_check/error/errorget.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ class variable or method annotated by this hint *or* :data:`None`).
# Suffix this exception prefix with an additional noun for disambiguity.
exception_prefix = (
f'{exception_prefix}value '
f'{prefix_pith_value(pith=obj, is_color=True)}'
f'{prefix_pith_value(pith=obj, is_color=conf.is_color)}'
)
# Else, the caller passed a parameter name. In this case...
else:
Expand All @@ -430,7 +430,7 @@ class variable or method annotated by this hint *or* :data:`None`).
exception_prefix = prefix_callable_return_value(
func=func, # type: ignore[arg-type]
return_value=obj,
is_color=True,
is_color=conf.is_color,
)
# Else, the passed object is a parameter. In this case...
else:
Expand All @@ -440,7 +440,7 @@ class variable or method annotated by this hint *or* :data:`None`).
func=func, # type: ignore[arg-type]
arg_name=pith_name,
arg_value=obj,
is_color=True,
is_color=conf.is_color,
)

# Uppercase the first character of this violation prefix for readability.
Expand Down
10 changes: 5 additions & 5 deletions beartype/_conf/confcls.py
Original file line number Diff line number Diff line change
Expand Up @@ -788,12 +788,12 @@ def kwargs(self) -> DictStrToAny:
# Arbitrary input beartype configuration.
conf = BeartypeConf(is_color=True)
# Permuted output beartype configuration keyword dictionary.
kwargs = conf.kwargs.copy()
kwargs['is_debug'] = True
# New keyword dictionary permuted from this input.
conf_kwargs = conf.kwargs.copy()
conf_kwargs['is_debug'] = True
# Output beartype configuration permuted from this input.
debug_conf = BeartypeConf(**kwargs)
# New beartype configuration initialized by this dictionary.
debug_conf = BeartypeConf(**conf_kwargs)
See Also
--------
Expand Down
163 changes: 140 additions & 23 deletions beartype/_decor/wrap/_wrapargs.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,29 @@

# ....................{ IMPORTS }....................
from beartype.roar import (
BeartypeDecorParamNameException,
BeartypeCallHintForwardRefException,
BeartypeDecorHintParamDefaultForwardRefWarning,
BeartypeDecorHintParamDefaultViolation,
BeartypeDecorHintPepException,
BeartypeDecorParamNameException,
)
from beartype._check.checkcall import BeartypeCall
from beartype._check.checkmake import make_code_raiser_func_pith_check
from beartype._check.convert.convsanify import sanify_hint_root_func
from beartype._conf.confcls import BeartypeConf
from beartype._data.error.dataerrmagic import EXCEPTION_PLACEHOLDER
from beartype._data.func.datafuncarg import ARG_NAME_RETURN
from beartype._decor.wrap.wrapsnip import (
CODE_INIT_ARGS_LEN,
EXCEPTION_PREFIX_DEFAULT_VALUE,
EXCEPTION_PREFIX_DEFAULT,
ARG_KIND_TO_CODE_LOCALIZE,
)
from beartype._decor.wrap._wraputil import unmemoize_func_wrapper_code
from beartype._util.error.utilerrraise import reraise_exception_placeholder
from beartype._util.error.utilerrwarn import reissue_warnings_placeholder
from beartype._util.error.utilerrwarn import (
issue_warning,
reissue_warnings_placeholder,
)
from beartype._util.func.arg.utilfuncargiter import (
ARG_META_INDEX_DEFAULT,
ARG_META_INDEX_KIND,
Expand All @@ -47,7 +54,11 @@
is_hint_needs_cls_stack,
)
from beartype._util.kind.map.utilmapset import update_mapping
from beartype._util.text.utiltextprefix import prefix_callable_arg_name
from beartype._util.text.utiltextmunge import lowercase_str_char_first
from beartype._util.text.utiltextprefix import (
prefix_callable_arg_name,
prefix_pith_value,
)
from beartype._util.utilobject import SENTINEL
from warnings import catch_warnings

Expand Down Expand Up @@ -79,8 +90,8 @@ def code_check_args(bear_call: BeartypeCall) -> str:
* A PEP-noncompliant type hint.
* A supported PEP-compliant type hint.
'''
assert bear_call.__class__ is BeartypeCall, (
f'{repr(bear_call)} not @beartype call.')
assert isinstance(bear_call, BeartypeCall), (
f'{repr(bear_call)} not beartype call.')

# ..................{ LOCALS ~ func }..................
# If *NO* callable parameters are annotated, silently reduce to a noop.
Expand All @@ -101,10 +112,6 @@ def code_check_args(bear_call: BeartypeCall) -> str:
# Python code snippet to be returned.
func_wrapper_code = ''

# ..................{ IMPORTS }..................
# Defer heavyweight imports prohibited at global scope.
from beartype.door import die_if_unbearable

# ..................{ LOCALS ~ parameter }..................
#FIXME: Remove this *AFTER* optimizing signature generation, please.
# True only if this callable possibly accepts one or more positional
Expand Down Expand Up @@ -192,19 +199,13 @@ def code_check_args(bear_call: BeartypeCall) -> str:
# print(f'Ignoring {bear_call.func_name} parameter {arg_name} hint {repr(hint)}...')
continue
# Else, this hint is unignorable.
#
# If this parameter is *NOT* mandatory, this parameter is
# optional and thus defaults to a default value. In this case...
elif arg_default is not ArgMandatory:
# If this default value violates this hint, raise a
# decoration-time violation exception.
die_if_unbearable(
obj=arg_default,
hint=hint,
exception_prefix=EXCEPTION_PREFIX_DEFAULT_VALUE,
)
# Else, this default value satisfies this hint.
# Else, this parameter is mandatory.

# If this parameter is optional *AND* the default value of this
# optional parameter violates this hint, raise an exception.
_die_if_arg_default_unbearable(
bear_call=bear_call, arg_default=arg_default, hint=hint)
# Else, this parameter is either optional *OR* the default value
# of this optional parameter satisfies this hint.

# If this parameter either may *OR* must be passed positionally,
# record this fact.
Expand Down Expand Up @@ -359,3 +360,119 @@ def code_check_args(bear_call: BeartypeCall) -> str:
:attr:`ArgKind` enumeration members signifying that a callable parameter
either may *or* must be passed positionally).
'''

# ....................{ PRIVATE ~ raisers }....................
def _die_if_arg_default_unbearable(
bear_call: BeartypeCall, arg_default: object, hint: object) -> None:
'''
Raise a violation exception if the annotated optional parameter of the
decorated callable with the passed default value violates the type hint
annotating that parameter at decoration time.
Parameters
----------
bear_call : BeartypeCall
Decorated callable to be type-checked.
arg_default : object
Either:
* If this parameter is mandatory, the :data:`.ArgMandatory` singleton.
* If this parameter is optional, the default value of this optional
parameter to be type-checked.
hint : object
Type hint to type-check against this default value.
Warns
-----
BeartypeDecorHintParamDefaultForwardRefWarning
If this type hint contains one or more forward references that *cannot*
be resolved at decoration time. While this does *not* necessarily
constitute a fatal error from the end user perspective, this does
constitute a non-fatal issue worth informing the end user of.
Raises
------
BeartypeDecorHintParamDefaultViolation
If this default value violates this type hint.
'''

# ..................{ PREAMBLE }..................
# If this parameter is mandatory, silently reduce to a noop.
if arg_default is ArgMandatory:
return
# Else, this parameter is optional and thus defaults to a default value.

assert isinstance(bear_call, BeartypeCall), (
f'{repr(bear_call)} not beartype call.')

# ..................{ IMPORTS }..................
# Defer heavyweight imports prohibited at global scope.
from beartype.door import (
die_if_unbearable,
is_bearable,
)

# ..................{ MAIN }..................
# Attempt to...
try:
# If this default value satisfies this hint, silently reduce to a noop.
#
# Note that this is a non-negligible optimization. Technically, this
# preliminary test is superfluous: only the call to the
# die_if_unbearable() raiser below is required. Pragmatically, this
# preliminary test avoids a needlessly expensive dictionary copy in the
# common case that this value satisfies this hint.
if is_bearable(
obj=arg_default,
hint=hint,
conf=bear_call.conf,
):
return
# Else, this default value violates this hint.
# If doing so raises a forward hint exception, this hint contains one or
# more unresolvable forward references to user-defined objects that have yet
# to be defined. In all likelihood, these objects are subsequently defined
# after the definition of this decorated callable. While this does *NOT*
# necessarily constitute a fatal error from the end user perspective, this
# does constitute a non-fatal issue worth informing the end user of. In this
# case, we coerce this exception into a warning.
except BeartypeCallHintForwardRefException as exception:
# Forward hint exception message raised above. To readably embed this
# message in the longer warning message emitted below, the first
# character of this message is lowercased as well.
exception_message = lowercase_str_char_first(str(exception))

# Emit this non-fatal warning.
issue_warning(
cls=BeartypeDecorHintParamDefaultForwardRefWarning,
message=(
f'{EXCEPTION_PREFIX_DEFAULT}value '
f'{prefix_pith_value(pith=arg_default, is_color=bear_call.conf.is_color)} '
f'uncheckable at @beartype decoration time, as '
f'{exception_message}'
),
)

# Loudly reduce to a noop. Since this forward reference is unresolvable,
# further type-checking attempts are entirely fruitless.
return

# Modifiable keyword dictionary encapsulating this beartype configuration.
conf_kwargs = bear_call.conf.kwargs.copy()

#FIXME: This should probably be configurable as well. For now, this is fine.
#We shrug noncommittally. We shrug, everyone! *shrug*
# Set the type of violation exception raised by the subsequent call to the
# die_if_unbearable() function to the expected type.
conf_kwargs['violation_door_type'] = BeartypeDecorHintParamDefaultViolation

# New beartype configuration initialized by this dictionary.
conf = BeartypeConf(**conf_kwargs)

# Raise this type of violation exception.
die_if_unbearable(
obj=arg_default,
hint=hint,
conf=conf,
exception_prefix=EXCEPTION_PREFIX_DEFAULT,
)
4 changes: 2 additions & 2 deletions beartype/_decor/wrap/_wrapreturn.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ def code_check_return(bear_call: BeartypeCall) -> str:
* **PEP-noncompliant** (i.e., :mod:`beartype`-specific type hint *not*
compliant with annotation-centric PEPs)).
'''
assert bear_call.__class__ is BeartypeCall, (
f'{repr(bear_call)} not @beartype call.')
assert isinstance(bear_call, BeartypeCall), (
f'{repr(bear_call)} not beartype call.')

# Type hint annotating this callable's return if any *OR* "SENTINEL"
# otherwise (i.e., if this return is unannotated).
Expand Down
2 changes: 1 addition & 1 deletion beartype/_decor/wrap/wrapsnip.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from collections.abc import Callable

# ....................{ STRINGS }....................
EXCEPTION_PREFIX_DEFAULT_VALUE = f'{EXCEPTION_PLACEHOLDER}default '
EXCEPTION_PREFIX_DEFAULT = f'{EXCEPTION_PLACEHOLDER}default '
'''
Non-human-readable source substring to be globally replaced by a human-readable
target substring in the messages of memoized exceptions passed to the
Expand Down
4 changes: 2 additions & 2 deletions beartype/_util/error/utilerrwarn.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ def reissue_warnings_placeholder(
) -> None:
'''
Reissue (i.e., re-emit) the passed warning in a safe manner preserving both
this warning object *and* **associated context (e.g., filename, line
number)** associated with this warning object, but globally replacing all
this warning object *and* **associated context** (e.g., filename, line
number) associated with this warning object, but globally replacing all
instances of the passed source substring hard-coded into this warning's
message with the passed target substring.
Expand Down
4 changes: 2 additions & 2 deletions beartype/_util/hint/pep/proposal/utilpep557.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ def get_hint_pep557_initvar_arg(
hint : object
Type hint to be inspected.
exception_cls : TypeException, optional
Type of exception to be raised. Defaults to
:exc:`BeartypeDecorHintPep557Exception`.
Type of exception to be raised in the event of a fatal error. Defaults
to :exc:`.BeartypeDecorHintPep557Exception`.
exception_prefix : str, optional
Human-readable substring prefixing the representation of this object in
the exception message. Defaults to the empty string.
Expand Down

0 comments on commit 0fccdb4

Please sign in to comment.