Skip to content

Commit

Permalink
Granular warning messages x 2.
Browse files Browse the repository at this point in the history
This commit is the next in a commit chain improving the granularity of
warning messages, en-route to resolving feature request #73 kindly
submitted several lifetimes ago by Stuttgart `typing` master @amogorkon
(Anselm Kiefner). Specifically, this commit exhaustively debugs and
tests our recently defined private
`beartype._util.error.utilerrwarn.reissue_warnings_placeholder()`
utility function that is key to this special madness.:

Thanks so much to @amogorkon for gently prodding us to do this several
lifetimes ago. This miracle on Earth is happening because of you.
(*Fractious fractions in an insatiate satisfaction!*)
  • Loading branch information
leycec committed Feb 27, 2024
1 parent c5a435d commit 438fe70
Show file tree
Hide file tree
Showing 11 changed files with 309 additions and 65 deletions.
2 changes: 1 addition & 1 deletion beartype/_check/_checksnip.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@


CODE_WARN_VIOLATION = f'''
{ARG_NAME_WARN}(cls=type({VAR_NAME_VIOLATION}), message=str({VAR_NAME_VIOLATION}))'''
{ARG_NAME_WARN}(str({VAR_NAME_VIOLATION}), type({VAR_NAME_VIOLATION}))'''
'''
Code snippet emitting the type-checking violation previously generated by the
:data:`.CODE_HINT_ROOT_SUFFIX` or
Expand Down
19 changes: 17 additions & 2 deletions beartype/_check/checkmake.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@
get_hint_pep484585_ref_names_relative_to)
from beartype._util.hint.utilhinttest import is_hint_ignorable
from itertools import count
from warnings import catch_warnings
from warnings import (
catch_warnings,
warn,
)

# ....................{ FACTORIES ~ func }....................
@callable_cached
Expand Down Expand Up @@ -806,7 +809,19 @@ def _make_code_raiser_violation(

# Pass the warnings.warn() function required to emit this warning to
# this wrapper function as an optional hidden parameter.
func_scope[ARG_NAME_WARN] = issue_warning
#
# Note that we intentionally do *NOT* pass the higher-level
# issue_warning() function. Why? Efficiency, mostly. Recall that
# issue_warning() is *ONLY* called to pretend that warnings generated by
# callables both defined by and residing in this codebase are actually
# generated by external third-party code. Although this wrapper function
# is also generated by callables defined by this codebase (including
# this callable, of course), this wrapper function does *NOT* reside
# inside this codebase but instead effectively resides inside the
# external third-party module defining the original function this
# wrapper function wraps. Needlessly passing issue_warning() rather than
# warn() here would only consume CPU cycles for *NO* tangible gain.
func_scope[ARG_NAME_WARN] = warn
# Else...
else:
# Raise a fatal exception.
Expand Down
1 change: 1 addition & 0 deletions beartype/_decor/wrap/wrapmain.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ def _code_check_args(bear_call: BeartypeCall) -> str:
# instance) replaced by a human-readable description of this
# callable and annotated parameter.
if warnings_issued:
# print(f'warnings_issued: {warnings_issued}')
reissue_warnings_placeholder(
warnings=warnings_issued,
target_str=prefix_beartypeable_arg(
Expand Down
17 changes: 4 additions & 13 deletions beartype/_util/error/utilerrraise.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# ....................{ IMPORTS }....................
from beartype._data.error.dataerrmagic import EXCEPTION_PLACEHOLDER
from beartype._util.error.utilerrtest import is_exception_message_str
from beartype._util.text.utiltextmunge import uppercase_str_char_first

# ....................{ RAISERS }....................
Expand Down Expand Up @@ -106,19 +107,9 @@ def reraise_exception_placeholder(
assert isinstance(source_str, str), f'{repr(source_str)} not string.'
assert isinstance(target_str, str), f'{repr(target_str)} not string.'

# If...
if (
# Exception arguments are a tuple (as is typically but not necessarily
# the case) *AND*...
isinstance(exception.args, tuple) and
# This tuple is non-empty (as is typically but not necessarily the
# case) *AND*...
exception.args and
# The first item of this tuple is a string providing this exception's
# message (as is typically but not necessarily the case)...
isinstance(exception.args[0], str)
# Then this is a conventional exception. In this case...
):

# If this is a conventional exception...
if is_exception_message_str(exception):
# Munged exception message globally replacing all instances of this
# source substring with this target substring.
#
Expand Down
45 changes: 45 additions & 0 deletions beartype/_util/error/utilerrtest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
# --------------------( LICENSE )--------------------
# Copyright (c) 2014-2024 Beartype authors.
# See "LICENSE" for further details.

'''
Project-wide **exception testers** (i.e., low-level callables introspecting
metadata associated with various types of exceptions).
This private submodule is *not* intended for importation by downstream callers.
'''

# ....................{ IMPORTS }....................

# ....................{ TESTERS }....................
#FIXME: Unit test us up, please.
def is_exception_message_str(exception: Exception) -> bool:
'''
:data:`True` only if the message encapsulated by the passed exception is a
simple string (as is typically but *not* necessarily the case).
Parameters
----------
exception : Exception
Exception to be inspected.
Returns
-------
bool
:data:`True` only if this exception's message is a simple string.
'''
assert isinstance(exception, Exception), f'{repr(exception)} not exception.'

# Return true only if...
return bool(
# Exception arguments are a tuple (as is typically but not necessarily
# the case) *AND*...
isinstance(exception.args, tuple) and
# This tuple is non-empty (as is typically but not necessarily the
# case) *AND*...
exception.args and
# The first item of this tuple is a string providing this exception's
# message (as is typically but *NOT* necessarily the case)...
isinstance(exception.args[0], str)
)
105 changes: 72 additions & 33 deletions beartype/_util/error/utilerrwarn.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@
'''

# ....................{ IMPORTS }....................
from beartype.typing import Iterable
from beartype.typing import (
Any,
Iterable,
)
from beartype._data.error.dataerrmagic import EXCEPTION_PLACEHOLDER
from beartype._data.hint.datahinttyping import TypeWarning
from beartype._util.error.utilerrtest import is_exception_message_str
from beartype._util.py.utilpyversion import IS_PYTHON_AT_LEAST_3_12
from beartype._util.text.utiltextmunge import uppercase_str_char_first
from collections.abc import Iterable as IterableABC
Expand All @@ -25,10 +29,6 @@
)

# ....................{ WARNERS }....................
#FIXME: Unit test us up, please.
#FIXME: Globally replace all direct calls to the warn() function with calls to
#this utility function instead, please.

# If the active Python interpreter targets Python >= 3.12, the standard
# warnings.warn() function supports the optional "skip_file_prefixes" parameter
# critical for emitting more useful warnings. In this case, define the
Expand All @@ -43,6 +43,7 @@
def issue_warning(cls: TypeWarning, message: str) -> None:
# The warning you gave us is surely our last!
warn(message, cls, skip_file_prefixes=_ISSUE_WARNING_IGNORE_DIRNAMES) # type: ignore[call-overload]
# warn(message, cls) # type: ignore[call-overload]

# ....................{ PRIVATE ~ globals }....................
_ISSUE_WARNING_IGNORE_DIRNAMES = (dirname(beartype.__file__),)
Expand Down Expand Up @@ -88,7 +89,7 @@ def issue_warning(cls: TypeWarning, message: str) -> None:
'''
)


# ....................{ REWARNERS }....................
def reissue_warnings_placeholder(
# Mandatory parameters.
warnings: Iterable[WarningMessage],
Expand Down Expand Up @@ -136,36 +137,74 @@ def reissue_warnings_placeholder(
assert isinstance(source_str, str), f'{repr(source_str)} not string.'
assert isinstance(target_str, str), f'{repr(target_str)} not string.'

# For each warning in this iterable of zero or more warnings...
for warning in warnings:
assert isinstance(warning, WarningMessage), (
f'{repr(warning)} not "WarningMessage" instance.')

# Original warning message, localized as a negligible optimization.
warning_message_old: str = warning.message # type: ignore[assignment]

# Munged warning message globally replacing all instances of this source
# substring with this target substring.
# For each warning descriptor in this iterable of zero or more warning
# descriptors...
for warning_info in warnings:
assert isinstance(warning_info, WarningMessage), ( # <-- terrible name!
f'{repr(warning_info)} not "WarningMessage" instance.')

# Original warning wrapped by this warning descriptor, localized both
# for readability *AND* negligible speed. *sigh*
warning = warning_info.message

# Munged warning message to be issued below.
warning_message_new: Any = None

# If this warning is... *ALREADY A STRING!?* What is going on here?
# Look. Not even we know. But mypy claims that warnings recorded by
# calls to the standard "warnings.catch_warnings(record=True)" function
# satisfy the union "Warning | str". Technically, that makes no sense.
# Pragmatically, that makes no sense. But mypy says it's true. We are
# too tired to argue with static type-checkers at 4:11AM in the morning.
if isinstance(warning, str): # pragma: no cover
warning_message_new = warning
# Else, this warning is actually a warning.
#
# Note that we intentionally call the lower-level str.replace() method
# rather than the higher-level
# beartype._util.text.utiltextmunge.replace_str_substrs() function here,
# as the latter unnecessarily requires this warning message to contain
# one or more instances of this source substring.
warning_message_new = warning_message_old.replace(
source_str, target_str)

# If doing so actually changed this message...
if warning_message_new != warning_message_old:
# Uppercase the first character of this message if needed.
warning_message_new = uppercase_str_char_first(warning_message_new)
# Else, this message remains preserved as is.
# If this is an conventional warning...
elif is_exception_message_str(warning):
# Original warning message, coerced from the original warning.
#
# Note that the poorly named "message" attribute is the original warning
# rather warning message. Just as with exceptions, coercing this warning
# into a string reliably retrieves its message.
warning_message_old = str(warning)

# Munged warning message globally replacing all instances of this source
# substring with this target substring.
#
# Note that we intentionally call the lower-level str.replace() method
# rather than the higher-level
# beartype._util.text.utiltextmunge.replace_str_substrs() function here,
# as the latter unnecessarily requires this warning message to contain
# one or more instances of this source substring.
warning_message_new = warning_message_old.replace(
source_str, target_str)

# If doing so actually changed this message...
if warning_message_new != warning_message_old:
# Uppercase the first character of this message if needed.
warning_message_new = uppercase_str_char_first(
warning_message_new)
# Else, this message remains preserved as is.
# Else, this is an unconventional warning. In this case...
else:
# Tuple of the zero or more arguments with which this warning was
# originally issued.
warning_args = warning.args

# Assert that this warning was issued with exactly one argument.
# Since the warnings.warn() signature accepts only a single
# "message" parameter, this assertion *SHOULD* always hold. *sigh*
assert len(warning_args) == 1

# Preserve this warning as is.
warning_message_new = warning_args[0]

# Reissue this warning with a possibly modified message.
warn_explicit(
message=warning_message_new,
category=warning.category,
filename=warning.filename,
lineno=warning.lineno,
source=warning.source,
category=warning_info.category,
filename=warning_info.filename,
lineno=warning_info.lineno,
source=warning_info.source,
)
4 changes: 2 additions & 2 deletions beartype/_util/hint/pep/proposal/utilpep613.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def reduce_hint_pep613(
f' from typing import TypeAlias\n'
f' alias_name: TypeAlias = alias_value\n'
f'\n'
f' # ...just do this.\n'
f' # ..."just" do this. Congrats. You destroyed your codebase.\n'
f' type alias_name = alias_value\n'
f'* Refactoring PEP 613 type aliases into PEP 484 '
f'"typing.NewType"-based type aliases. Note that static '
Expand All @@ -74,7 +74,7 @@ def reduce_hint_pep613(
f' from typing import TypeAlias\n'
f' alias_name: TypeAlias = alias_value\n'
f'\n'
f' # ...just do this.\n'
f' # ..."just" do this. Congrats. You destroyed your codebase.\n'
f' from typing import NewType\n'
f' alias_name = NewType("alias_name", alias_value)\n'
f'\n'
Expand Down
10 changes: 7 additions & 3 deletions beartype/_util/module/utilmoddeprecate.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,13 @@ def deprecate_module_attr(
# Emit a non-fatal warning of the standard "DeprecationWarning"
# category, which CPython filters (ignores) by default.
#
# Note that we intentionally do *NOT* emit a non-fatal warning of our
# non-standard "BeartypeDecorHintPepDeprecationWarning" category, which
# applies *ONLY* to PEP-compliant type hint deprecations.
# Note that we intentionally:
# * Do *NOT* emit a non-fatal warning of our non-standard
# "BeartypeDecorHintPepDeprecationWarning" category, which applies
# *ONLY* to PEP-compliant type hint deprecations.
# * Do *NOT* call the higher-level issue_warning() function, which would
# erroneously declare that this deprecation originates from the
# external caller rather than this codebase itself.
warn(
(
f'Deprecated attribute '
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
#!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

# ....................{ TESTS }....................
def test_reraise_exception_cached() -> None:
def test_reraise_exception_placeholder() -> None:
'''
Test the
:func:`beartype._util.error.utilerrraise.reraise_exception_placeholder`
Expand All @@ -34,9 +34,9 @@ def test_reraise_exception_cached() -> None:
# ....................{ CLASSES }....................
class CachedException(ValueError):
'''
Test-specific exception raised by unit tests exercising the
:func:`beartype._util.error.utilerrraise.reraise_exception_placeholder`
function.
Test-specific exception intended to be raised below with messages
containing placeholder substrings replaced by the
:func:`.reraise_exception_placeholder` re-raiser.
'''

pass
Expand All @@ -47,20 +47,27 @@ class CachedException(ValueError):
TEST_SOURCE_STR = '{its_got_bro}'

# ....................{ CALLABLES }....................
# Low-level memoized callable raising non-human-readable exceptions
# conditionally depending on the value of passed parameters.
@callable_cached
def portend_low_level_winter(is_winter_coming: bool) -> str:
'''
Low-level memoized callable raising unreadable exceptions conditionally
depending on the value of the passed parameter.
'''

if is_winter_coming:
raise CachedException(
f'{TEST_SOURCE_STR} intimates that winter is coming.')
else:
return 'PRAISE THE SUN'

# High-level non-memoized callable calling the low-level memoized callable
# and reraising non-human-readable exceptions raised by the latter with
# equivalent human-readable exceptions.

def portend_high_level_winter() -> None:
'''
High-level non-memoized callable calling the low-level memoized callable
and reraising unreadable exceptions raised by the latter with equivalent
readable exceptions.
'''

try:
# Call the low-level memoized callable without raising exceptions.
print(portend_low_level_winter(False))
Expand All @@ -85,12 +92,12 @@ def portend_high_level_winter() -> None:
with raises(CachedException) as exception_info:
portend_high_level_winter()

# Assert this exception's message does *NOT* contain the non-human-readable
# Assert that this exception message does *NOT* contain the unreadable
# source substring hard-coded into the messages of all exceptions raised by
# this low-level memoized callable.
assert TEST_SOURCE_STR not in str(exception_info.value)

# Assert that exceptions messages may contain *NO* source substrings.
# Assert that exception messages may also contain *NO* source substrings.
try:
raise CachedException(
"What's bravery without a dash of recklessness?")
Expand Down

0 comments on commit 438fe70

Please sign in to comment.