Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adding math.TypeHint and is_subclass #136

Merged
merged 29 commits into from Jul 6, 2022
Merged

Conversation

tlambert03
Copy link
Contributor

closes #133

lots of things to be cleaned-up, documented, and discussed here. But opening a PR to discuss progress on this.

You can see currently passing checks in test_is_subtype (not sure where I should be putting the test in the suite).

Will ping you with more specific questions/topics when ready

@leycec
Copy link
Member

leycec commented Jun 4, 2022

Brilliance personified. I cannot believe you bit-banged that out in a night. But, Harvard, so... I guess I can believe. You amaze even as you stun, @tlambert03.

Welp. Let's get this git party started. I'll probably subject all of us to a mind-numbing peer review that makes us question the wisdom of the open-source crowd-sourced model – at some point. Because this is Friday night, this isn't that point. Rejoice! Instead...

You can see currently passing checks in test_is_subtype

😮

(not sure where I should be putting the test in the suite).

...heh. Yeah. So, our test suite is obsessive-compulsively organized to guarantee that unit tests exercising lower-level functionality are run before unit tests exercising higher-level functionality. Wouldn't want to test the public @beartype decorator itself before first testing that all of the private utility functions inside the beartype._util subpackage called by that decorator behave as expected, right?

Thankfully, nothing and nobody else actually use the beartype.math subpackage. ...yet It's actually fine for now to just stuff the test_is_subtype submodule directly into the beartype_test package – exactly as you've wisely done.

Eventually, a reasonable landing place for that submodule would probably be within a new test subpackage living at:

beartype_test.a00_unit.a60_api.math.test_is_subtype

Dissecting that:

  • a00_unit forces lower-level unit tests to run before the higher-level "functional" integration tests in a90_func.
  • a60_api forces mid-level API unit tests to run after both the lower-level utility tests in a20_util and PEP compliance tests in a40_pep but before both the higher-level error-handling tests in a80_error and decoration tests in a90_decor.

Unrelatedly, I now sadly note that...

Type Hints Are Only Partially Ordered

Ohnoes! I committed the cardinal sin of wishful thinking when I applied the @functools.total_ordering decorator to the beartype.math.TypeHint class.

Type hints almost constitute a total ordering under subtype compatibility; they satisfy reflexivity, transitivity, and antisymmetry as expected. Tragically for us, they violate totality (i.e., strongly connected). Bleakly consider:

>>> from collections.abc import Awaitable
>>> TypeHint(list[int]) <= TypeHint(Awaitable[str])
False
>>> TypeHint(Awaitable[str]) <= TypeHint(list[int])
False

Right? Okay. So that means type hints violate totality. If type hints satisfied totality, then either TypeHint(list[int]) <= TypeHint(Awaitable[str]) or TypeHint(Awaitable[str]) <= TypeHint(list[int]) would be True. But both conditions are False, because a container type hint like list[int] and a callable type hint like Awaitable[str] are incommensurable. The type hierarchies they describe are completely unrelated.

There's Probably a Point to All This?

Shockingly, there is. Because type hints only constitute a partial ordering under subtype compatibility, we can't apply the @functools.total_ordering decorator. Instead, we'll need to manually define each of Python's six rich comparison operators ourselves:

  • __eq__().
  • __ne__().
  • __ge__().
  • __gt__().
  • __le__().
  • __lt__().

This should still mostly be trivial. Three of those can be readily defined in terms of the other three: e.g.,

def __ne__(self, other: TypeHint) -> bool:
    return not self.__eq__(other)

def __gt__(self, other: TypeHint) -> bool:
    return self.__ge__(other) and self.__ne__(other)

def __lt__(self, other: TypeHint) -> bool:
    return self.__le__(other) and self.__ne__(other)

That only leaves __eq__() and __ge__(), really.

Annnnnyway... Your work is brilliance and these are conundrums for another evening. For tonight we play video games until dawn and dream bleary-eyed of a creepy AI that will someday code all this for us and submit PRs with our usernames on them. 🤖

@tlambert03
Copy link
Contributor Author

tlambert03 commented Jun 5, 2022

Okay. So that means type hints violate totality. If type hints satisfied totality, then either TypeHint(list[int]) <= TypeHint(Awaitable[str]) or TypeHint(Awaitable[str]) <= TypeHint(list[int]) would be True. But both conditions are False, because a container type hint like list[int] and a callable type hint like Awaitable[str] are incommensurable. The type hierarchies they describe are completely unrelated.

Yeah, I've been having this thought as I work on it. Types are trees or graphs, not lines. So, strictly speaking, ordering will probably not make too much sense in most cases.

Put another way, the same thing you showed above for Awaitable and List can also be said of sets:

In [1]: a = {1,2,3}

In [2]: b = {2,3,4}

In [3]: a <= b
Out[3]: False

In [4]: b <= a
Out[4]: False

I don't think it makes a huge difference though. Ultimately, we're just using the __le__ to mean "is this thing a subclass of other thing", just with the cute bonus of getting to use the <= operator. But couldn't we just dispense with the total_ordering decorator altogether, and directly use an is_subtype(other) method... (and if we want to have a nice operator, we can still use __le__ and __ge__ to match python set's issubset and issuperset behavior?

leycec added a commit that referenced this pull request Jun 7, 2022
This commit is the first in a commit chain improving our private
`beartype._util.hint.pep.utilpepget.get_hint_pep_args()` getter to
**deflatten** (i.e., restore to a facsimile of their original form)
`Callable[...]` type hints passed to that getter, en-route to resolving
issue #137 kindly submitted by Harvard microscopist and all-around
typing math guru @tlambert03 (Talley Lambert), required as a mandatory
dependency of pull request #136 also kindly submitted by @tlambert03.
Specifically, this commit lightly refactors that getter in preparation
for subsequent surgery as well as detailing internal improvements
needed elsewhere for this to fully behave as expected. (*Long-tallied talons!*)
@tlambert03
Copy link
Contributor Author

ok, this is getting a little closer to something I think I'd actually be able to use. plenty of tests are working, tuples, callables... (still need to do generators). I removed total ordering in favor of directly making set-like methods (but kept the ordered operators). I wonder if we might want to soften some of the comment language about total ordering to use more set-like language (maybe even the module name "math"?)

But I'm still kinda just "doing shit until it mostly works". At your convenience, I'd appreciate a look at the general patterns.

The comments are far from beartype standards! but mostly want to make sure we're not scratching our back on the wrong tree here 😂

At some point, I'll welcome you to take this over and "beartypify" it directly if you want to get it across the finish line!

@leycec
Copy link
Member

leycec commented Jun 8, 2022

O frabjous day! Your reign of excellence continues, I see. Now, let's see...

But couldn't we just... directly use an is_subtype(other) method... (and if we want to have a nice operator, we can still use __le__ and __ge__ to match python set's issubset and issuperset behavior?

Indeed, we can and we should do all those things! And I see that you have now done them. It's like you have a crystal ball into my brain, which is honestly kinda unsettling. 🔮

Relatedly, as a user convenience, we might also consider publicly exposing this convenience tester you've helpfully privatized at the top of beartype.math._mathcls:

def _is_subtype(subt: object, supert: object) -> bool:
    return TypeHint(subt) <= TypeHint(supert)

The subt and supert parameter names are a bit awkward for a public function. So, how about...

# In "beartype.math._mathcls":
def is_subtype(subtype: object, supertype: object) -> bool:
    '''
    Insert docstring for great glory here!
    '''

    return TypeHint(subtype) <= TypeHint(supertype)

# In "beartype.math.__init__":
from beartype.math._mathcls import is_subtype

I removed total ordering in favor of directly making set-like methods (but kept the ordered operators).

...excellent. Your prowess as an API designer precedes you. That's good.

I wonder if we might want to soften some of the comment language about total ordering to use more set-like language...

Indeedy! Speaking of, would you mind globally replacing the substring "total" with "partial" everywhere in this PR? In theory, that should make all the wrong things right again.

Technically, the set of all possible type hints is a partially ordered countably infinite set. Pragmatically, Python doesn't do the whole "countably infinite set" thing out-of-the-box. It does do the whole "partially ordered" thing out-of-the-box. So, in the interest of clarity, I'd kinda prefer the latter if you don't mind too terribly much.

Also, there are some really interesting well-known algorithms and data structures you can apply to partial orders. Topological sorts and network clustering sort of stuff. What could be funner? I mean, really. By highlighting that beartype.math defines a partial order over type hints, we're really highlighting that beartype.math enables type hints to be algorithmically reasoned with.

Okay. I'm not saying we should actually do this, but... we could augment TypeHint with __add__() and __sub__() dunder methods backed by typing.Union[...] type hints, in which case beartype.math would then be defining a partially ordered group (G, +) over type hints. Woah, bro! beartype.math would then expose the ponderous weight of algebraic group theory to the previously prosaic world of type hints.

Pinch me. I must be dreaming. Let's not do this in this PR. Possibly ever.

Comments Continue

Okay. Let's get pragmatic, yo! Things we might want to do – possibly even in this PR:

  • Define a trivial TypeHint.__hash__() dunder method in terms of the underlying type hint, which is (...more or less) mandatory whenever you define the __eq__() dunder method. The two go hand-in-hand, because otherwise users won't be able to hash TypeHint objects into dictionaries and sets: e.g.,
    def __hash__(self) -> int:
        return hash(self._hint)
  • Define a less trivial TypeHint.__eq__() dunder method. The current approach is super-clever, because it just defers to various __eq__() implementations in the typing module and Python's C layer. Sadly, that's also the problem:
>>> import typing as t

# This is good.
>>> t.List[str] == t.List[str]
True

# This is less good.
>>> list[str] == t.List[str]
False

An alternative approach would be for our __eq__() implementation to leverage (...wait for it) @beartype's get_hint_pep_sign_or_none() to equate semantically identical PEP 484- and 585-compliant type hints. Say, like this:

class TypeHint(object):
    ...

    def __eq__(self, other: object) -> Union[bool, NotImplemented]:

        # If that object is *NOT* an instance of the same class, defer to the
        # __eq__() method defined by the class of that object instead.
        if not isinstance(other, TypeHint):
            return NotImplemented
        # Else, that object is an instance of the same class.

        #FIXME: Maybe store as "self._hint_sign" to avoid lookups everywhere?
        # Sign uniquely identifying this and that hint if any *OR* "None"
        # (i.e., if either of these hints are *NOT* actually type hints).
        self_sign  = get_hint_pep_sign_or_none(self._hint)
        other_sign = get_hint_pep_sign_or_none(other._hint)

        # If either...
        if (
            # These hints have differing signs, these hints are unequal *OR*...
            self_sign is not other_sign or
            # These hints have a differing number of child type hints...
            len(self._hints_child_ordered) != len(other._hints_child_ordered)
        ):
            # Then these hints are unequal.
            return False
        # Else, these hints share the same sign and number of child type hints.

        # Return true only if all child type hints of these hints are equal.
        return all(
            self._hints_child_ordered[hint_child_index] ==
            other._hints_child_ordered[hint_child_index]
            for hint_child_index in range(self._hints_child_ordered)
        )


class _TypeHintClass(object):
    ...

    def __eq__(self, other: object) -> Union[bool, NotImplemented]:

        # If that object is *NOT* a partially ordered type hint, defer to the
        # __eq__() method defined by the class of that object instead.
        if not isinstance(other, TypeHint):
            return NotImplemented
        # Else, that object is a partially ordered type hint.

        # Return true only if these hints describe the same class.
        return isinstance(other, _TypeHintClass) and self._hint is other._hint
  • Decorate TypeHint.__new__() by @callable_cached, which should then ensure that the TypeHint() factory only efficiently produces singletons: e.g.,
from beartype._util.cache.utilcachecall import callable_cached

class TypeHint(object):
    ...

    @callable_cached
    def __new__(cls, hint: object) -> "TypeHint": ...

Given that, this constraint should now be true for any arbitrary type hint hint:

>>> TypeHint(hint) is TypeHint(true)
True
  • There's a slight complication with respect to our rich comparison operators deferring to the is_subtype() and is_supertype() methods. Don't get me wrong! Those methods are great. The PEP standard for rich comparison operators is a bit... wonky, however. Basically, those operators should return NotImplemented rather than raise an exception when the passed object is not an instance of TypeHint. Returning NotImplemented is the polite thing to do, because it allows the other object to have a say in the matter. Those methods are fine as is, thankfully. Sadly, we'll have to do this the manual way in those operators: e.g.,
    def __le__(self, other: object) -> bool:
        """Return true if self is a subtype of other."""
        if not isinstance(other, TypeHint):
            return NotImplemented

        return self.is_subtype(other)

le sigh.

  • repr() strings should, ideally, be evaluable as Python expressions. Doing so makes things a little more consistent across the Python ecosystem, which is nice. Happily, that's trivial here: e.g.,
    def __repr__(self) -> str:
        return f"TypeHint({repr(self._hint)})"

And now for the big guns...

typing.Annotated or Bust

In #137, I suggested many things. Let's not do those things. Instead, let's do this:

from beartype._util.hint.pep.proposal.utilpep593 import (
    get_hint_pep593_metadata,
    get_hint_pep593_metahint,
)

class _TypeHintAnnotated(TypeHint):

    #FIXME: Define __eq__() in a similar way, probably? Joy is type hints.
    def _is_le_branch(self, branch: TypeHint) -> bool:

        # If that hint is *NOT* a "typing.Annotated[...]" type hint, this hint
        # *CANNOT* be a subtype of that hint. In this case, return false.
        if not isinstance(branch, _TypeHintAnnotated):
            return False
        # Else, that hint is a "typing.Annotated[...]" type hint.

        # Child type hints annotated by these parent "typing.Annotated[...]"
        # type hints (i.e., the first arguments subscripting these hints).
        self_metahint = get_hint_pep593_metahint(self._hint)
        other_metahint = get_hint_pep593_metahint(branch._hint)

        # Tuples of zero or more arbitrary caller-defined objects annotating by
        # these parent "typing.Annotated[...]" type hints (i.e., all remaining
        # arguments subscripting these hints).
        self_metadata = get_hint_pep593_metadata(self._hint)
        other_metadata = get_hint_pep593_metadata(branch._hint)

        # If either...
        if (
            # The child type hint annotated by this parent hint does not subtype
            # the child type hint annotated by that parent hint *OR*...
            TypeHint(self_metahint) > TypeHint(other_metahint) or
            # These hints are annotated by a differing number of objects...
            len(self_metadata) != len(self_metadata)
        ):
            # This hint *CANNOT* be a subtype of that hint. Return false.
            return False

        # Attempt to...
        #
        # Note that the following iteration performs equality comparisons on
        # arbitrary caller-defined objects. Since these comparisons may raise
        # arbitrary caller-defined exceptions, we silently squelch any such
        # exceptions that arise by returning false below instead.
        try:
            # Return true only if these hints are annotated by equivalent
            # objects. We avoid testing for a subtype relation here (e.g., with
            # the "<=" operator), as arbitrary caller-defined objects are *MUCH*
            # more likely to define a relevant equality comparison than a
            # relevant less-than-or-equal-to comparison.
            return all(
                self_metadata[metadata_index] == other_metadata[metadata_index]
                for metadata_index in range(self_metadata)
            )
        except:
            pass

        # Else, one or more objects annotating these hints are incomparable. So,
        # this hint *CANNOT* be a subtype of that hint. Return false.
        return False

In theory, that should let us compare beartype validators against one another. Will it? You decide.

Okay. So, let's talk type hints. Generally speaking, there are two kinds:

  • Unstructured type hints, subscripted (indexed) by zero or more child type hints. Order is important, but there's no internal structure or implied semantics in that order. Most type hints are unstructured – like, 90% or more of all type hints, easily. Examples include unions and most type hints with an origin. Given an unstructured type hint, calling @beartype's low-level get_hint_pep_args() getter returns a semantically meaningful result.
  • Structured type hints, subscripted (indexed) by two or more child type hints. Order is important. However, there's now an internal structure with implied semantics in that order. Examples include typing.Annotated[...], typing.Callable[...], typing.(Async|)Generator[...], and probably a few more? You probably see where this is going. Given a structured type hint, calling @beartype's low-level get_hint_pep_args() getter mostly does not return a semantically meaningful result.

This isn't a bad thing, though. typing.get_args() tries hard to pretend that structured type hints are unstructured, which then causes problems elsewhere. get_hint_pep_args() doesn't bother pretending. Structured type hints are structured. It's best to preserve that structure, right? There are useful semantics in that structure.

Instead, @beartype provides a suite of higher-level getters retrieving that structure. These getters are specific to each category of type hint. Above, we saw the get_hint_pep593_metadata() and get_hint_pep593_metahint() getters specific to PEP 593-style typing.Annotated[...] type hints. B-b-but what about...

typing.Callable or Bust

Busted. We haven't implemented getters for Callable[...] type hints yet. Because of that, let's defer handling typing.Callable and callable.abc.Callable for a subsequent PR. Would that be okay with you?

It'll take me a day or two to doctor those getters up. There are at least three PEPs and more than a few edge cases across varying Python versions involved. I'd like to nail those unit tests properly. Once complete, these getters will resemble:

  • A new beartype._util.hint.pep.proposal.pep484585.utilpep484585callable.get_hint_pep484585_callable_args() returning either:
    • A tuple of the zero or more parameter type hints subscripting (indexing) the passed Callable[...] type hint, if that hint was of the form Callable[[{arg_hints}], {return_hint}].
    • Ellipsis, if that hint was of the form Callable[..., {return_hint}].
    • typing.ParamSpec[...], if that hint was of the form Callable[typing.ParamSpec[...], {return_hint}].
  • A new beartype._util.hint.pep.proposal.pep484585.utilpep484585callable.get_hint_pep484585_callable_return() returning the return type hint subscripting (indexing) the passed Callable[...] type hint.

For similar reasons, I hope you don't mind if I close issue #137 out on you. Surprisingly, the current behaviour of get_hint_pep_args() is (...mostly) working as intended. Let me know if you hit any other deep snags and we'll bail you out with our pitifully small and leaky bailing can.

Phew!

And... We're Almost There

So, you're in total and complete control over this PR. You control the horizontal and the vertical. Do as little or as much as you'd like. When you're ready, just let me know and I'll immediately merge this.

This is stellar output, @tlambert03. Colour me incredibly impressed. Just like me, this decorative emoji with mouth agape is both bald and in stunned awe of your formidable strength. 😮

leycec added a commit that referenced this pull request Jun 8, 2022
This commit is the first in a commit chain defining a new internal
private API for introspecting ``Callable[...]`` type hints at runtime,
required as a mandatory dependency of pull request #136 kindly submitted
by Harvard microscopist and all-around typing math guru @tlambert03
(Talley Lambert). Specifically, this commit defines:

* A new private
  `beartype._util.hint.pep.proposal.pep484585.utilpep484585callable`
  submodule.
* In that submodule, a new `get_hint_pep484585_callable_args()` getter
  introspecting the parameter subtype hint subscripting (indexing) a
  ``Callable[...]`` type hint.

(*Damaging imaging, dame!*)
leycec added a commit that referenced this pull request Jun 9, 2022
This commit is the next in a commit chain defining a new internal
private API for introspecting ``Callable[...]`` type hints at runtime,
required as a mandatory dependency of pull request #136 kindly submitted
by Harvard microscopist and all-around typing math guru @tlambert03
(Talley Lambert). Specifically, this commit defines a new private
`beartype._util.hint.pep.proposal.pep484585.utilpep484585callable._die_unless_hint_pep484585_callable()`
validator validating the passed object to be such a type hint as well
as preliminary tests exercising this functionality. (*Insecure sinecure!*)
leycec added a commit that referenced this pull request Jun 10, 2022
This commit is the next in a commit chain defining a new internal
private API for introspecting ``Callable[...]`` type hints at runtime,
required as a mandatory dependency of pull request #136 kindly submitted
by Harvard microscopist and all-around typing math guru @tlambert03
(Talley Lambert). Specifically, this commit:

* Generalizes our existing low-level private
  `beartype._util.hint.pep.utilpepget.get_hint_pep_sign()` getter to
  optionally accept `exception_cls` and `exception_prefix` parameters.
* Improves our new higher-level private
  `_die_unless_hint_pep484585_callable()` validator to pass those
  parameters to that getter, unifying the exception types raised by that
  validator and simplifying unit tests of that validator.

(*Robust robots or bust!*)
leycec added a commit that referenced this pull request Jun 14, 2022
This commit is the next in a commit chain defining a new internal
private API for introspecting ``Callable[...]`` type hints at runtime,
required as a mandatory dependency of pull request #136 kindly submitted
by Harvard microscopist and all-around typing math guru @tlambert03
(Talley Lambert). Specifically, this commit almost finalizes the draft
implementation of our new high-level
`get_hint_pep484585_callable_args()` getter, which currently only lacks
support for `typing.ParamSpec[...]` and `typing.Concatenate[...]` child
type hints. (*Actionable red redaction!*)
leycec added a commit that referenced this pull request Jun 15, 2022
This commit is the next in a commit chain defining a new internal
private API for introspecting ``Callable[...]`` type hints at runtime,
required as a mandatory dependency of pull request #136 kindly submitted
by Harvard microscopist and all-around typing math guru @tlambert03
(Talley Lambert). Specifically, this commit almost finalizes the draft
implementation of our new high-level
`get_hint_pep484585_callable_args()` getter with support for
`typing.ParamSpec[...]` and `typing.Concatenate[...]` child type hints.
Further refactoring is warranted for collective sanity. (*Irreducible dulcimer!*)
@leycec
Copy link
Member

leycec commented Jul 6, 2022

beartype.math API.

This commit by Harvard microscopist and general genius @tlambert03
defines a new public beartype.math subpackage for performing type
hint arithmetic, resolving issues #133 and #138 kindly also submitted
by @tlambert03. Specifically, this commit defines a:

  • Public beartype.math.TypeHint({type_hint}) class, enabling rich
    comparisons between pairs of arbitrary type hints. Altogether, this
    class implements a partial ordering over the countably infinite set
    of all type hints. Pedagogical excitement ensues.
  • Public beartype.math.is_subtype({type_hint_a}, {type_hint_b})
    class, enabling @beartype users to decide whether any type hint
    is a subtype (i.e., narrower type hint) of any other type hint.

Thanks so much to @tlambert03 for his phenomenal work here.
(Compelling compulsion of propulsive propellers!)

@leycec leycec merged commit 7809ea2 into beartype:main Jul 6, 2022
@leycec
Copy link
Member

leycec commented Jul 6, 2022

💥 💥 💥

@leycec
Copy link
Member

leycec commented Jul 6, 2022

Yay! Our test suite once again passes – except for pyright, of course, which yells nonsensically at the top of its perfidious lungs about four non-issues:

No configuration file found.
No pyproject.toml file found.
Assuming Python platform Linux
Searching for source files
Found 180 source files
pyright 1.1.254
/home/leycec/py/beartype/beartype/math/_mathcls.py
  /home/leycec/py/beartype/beartype/math/_mathcls.py:25:33 - error: "ParamSpec" is unknown import symbol (reportGeneralTypeIssues)
  /home/leycec/py/beartype/beartype/math/_mathcls.py:476:72 - error: Argument of type "Unknown | None" cannot be assigned to parameter "__class_or_tuple" of type "type | tuple[type | tuple[Any, ...], ...]" in function "isinstance"
    Type "Unknown | None" cannot be assigned to type "type | tuple[type | tuple[Any, ...], ...]"
      Type "None" cannot be assigned to type "type | tuple[type | tuple[Any, ...], ...]"
        Type "None" cannot be assigned to type "type"
        Type "None" cannot be assigned to type "tuple[type | tuple[Any, ...], ...]" (reportGeneralTypeIssues)
  /home/leycec/py/beartype/beartype/math/_mathcls.py:476:72 - error: Second argument to "isinstance" must be a class or tuple of classes
    TypeVar or generic type with type arguments not allowed (reportGeneralTypeIssues)
  /home/leycec/py/beartype/beartype/math/_mathcls.py:579:44 - error: Index 0 is out of range for type tuple[()] (reportGeneralTypeIssues)
4 errors, 0 warnings, 0 informations 
Completed in 7.873sec

Silence, pyright! Or taste bloody steel. ⚔️

@tlambert03 tlambert03 deleted the math-mod branch July 7, 2022 11:31
@tlambert03
Copy link
Contributor Author

hooray! Thanks for all the comments and tips... Even though you generously merged anyway, I'll follow up with some improvements based on these suggestions

@leycec
Copy link
Member

leycec commented Jul 8, 2022

You're most welcome – and thanks yet again for this Big Bump to @beartype. 🤗

Please punch my avatar if I accidentally stomp on your parallel efforts, but I'll be massaging the beartype.math subpackage myself a heap tonight. Priorities include:

  • ✔️ [done] Renaming beartype.math. N-n-now... hear me out here. I came up with a ludicrous acronym and we're going to have to learn to live with it: the Decidedly Object-Orientedly Recursive (DOOR) API. Or, beartype.door for short. Open the door to a whole new type-hinting world, everyone. 🚪
  • ✔️ [done] Moving beartype_test.test_is_subtype somewhere deeper and more secretive in the creaky bowels of our test suite. More on that as I do something. Update: I did something. beartype_test.test_is_subtype now resides at beartype_test.a00_unit.a60_api.door.test_door forevermore. ...quoth the raven.
  • Restoring beartype_test.test_is_subtype to a semblance of worky under Python < 3.9. This day, all test failures die. (Yes, that was a mandatory oblique reference to This Day All Gods Die, the greatest final book and greatest book title of any scifi series evar – or our Maine Coon will eat my hat.)
  • Resolving all pending false positives from pyright. The less everyone says about pyright, the better.
  • Splitting beartype.door._mathcls into subsidiary submodules for maintainability. Really, though, it's just to assuage my crippling obsessive-compulsive disorder.

Oh, and I just stumbled over @napari on my way to GitHub work today. I am bedazzled. If @napari isn't the most impressive biomedical-oriented 3D Python viewer evar, I will livestream our Maine Coon eating my Tilley Hat sprinkled with catnip. Permissive licensing is only the cherry on top of your world-class crêpe. Educated prediction: @napari dominates client-side 3D visualization in 2022. Just... wowza. 😮

leycec added a commit that referenced this pull request Jul 8, 2022
This commit is the next in a commit chain repairing currently failing
tests pertaining to the `beartype.door` subpackage, recently implemented
by Harvard microscopist and general genius @tlambert03 in PR #136.
Specifically, this commit:

* Renames `beartype.math` to `beartype.door`. N-n-now... hear me out
  here. I came up with a ludicrous acronym and we're going to have to
  learn to live with it: the **D**ecidedly **O**bject-**O**rientedly
  **R**ecursive (DOOR) API. Or, `beartype.door` for short. Open the door
  to a whole new type-hinting world, everyone.
* Refactored `beartype_test.test_is_subtype` to defer unsafe
  instantiation of non-trivial global variables to test-time via
  `pytest` fixtures.
* Shifted `beartype_test.test_is_subtype` to
  `beartype_test.a00_unit.a60_api.door.test_door` for disambiguity and
  testability.

Since `beartype.door` remains critically broken under both Python 3.7
and 3.8, tests remain disabled until the Typing Doctor gets in.
(*Hazardous hazmats are arduous!*)
@leycec
Copy link
Member

leycec commented Jul 8, 2022

Catch! 🏀

If you'd like, the ball's in your court. Tests still fail under Python 3.7 and 3.8, but we're considerably closer. When you get a free minute, ...so never is what I'm saying would you mind taking a look at the failing test_is_subhint() test under Python 3.8?

I may have broken something with all of my shifty behaviour. If so, I profusely apologize. If not, I spit into the wind! Still, something smells disgustingly fishy there. It might be me. But it might not be me.

leycec added a commit that referenced this pull request Jul 8, 2022
This commit is the next in a commit chain repairing currently failing
tests pertaining to the `beartype.door` subpackage, recently implemented
by Harvard microscopist and general genius @tlambert03 in PR #136.
Specifically, this commit conditionally ignores `beartype.door` tests
under Python < 3.9 -- ensuring these tests at least continue to run
under Python >= 3.9. (*Formidable formulations of maidenless middens!*)
@leycec
Copy link
Member

leycec commented Jul 8, 2022

I can confirm that the test_is_subhint() test passes under Python ≥ 3.9. 🥳

Until we can decipher what exactly went wrong under older Python versions, our test suite now conditionally ignores that test under Python < 3.9 – ensuring tests continue to run under at least Python ≥ 3.9.

Let's see what insights a debauched Friday evening brings.

@leycec
Copy link
Member

leycec commented Jul 9, 2022

Test party continues. test_typehint_new() now unconditionally passes everywhere and test_typehint_equals() now passes under Python ≥ 3.9. Python 3.7 and 3.8 remain hostile to our interests. What you gonna do?

Gods, but I hope the answer is: "Go to sleep, you."

leycec added a commit that referenced this pull request Jul 9, 2022
This commit is the next in a commit chain repairing currently failing
tests pertaining to the `beartype.door` subpackage, recently implemented
by Harvard microscopist and general genius @tlambert03 in PR #136.
Specifically, this commit:

* Restores the `test_typehint_new()` unit test, which now successfully
  passes on all Python versions.
* Conditionally ignores the `test_typehint_equals()` unit test, which
  continues failing under Python < 3.9 -- ensuring this test at least
  continues to run under Python >= 3.9.

(*Unindented dentition of dendritic orientations!*)
leycec added a commit that referenced this pull request Jul 12, 2022
This commit is the next in a commit chain repairing currently failing
tests pertaining to the `beartype.door` subpackage, recently implemented
by Harvard microscopist and general genius @tlambert03 in PR #136.
Specifically, this commit:

* Declares a new `beartype.roar.BeartypeDoorNonpepException` type,
  raised when the `beartype.roar.TypeHint` constructor is passed an
  object that is *not* a PEP-compliant type hint currently supported by
  the DOOR API.
* Extricates the `beartype.door._doorcls.is_subclass()` and
  `beartype.door._doorcls.die_unless_typehint()` functions into a new
  `beartype.door._doortest` submodule for maintainability.
* Folds the prior `test_typehint_fail()` unit test into the existing
  `test_typehint_new()` unit test.

(*Thunderous blunderbuss!*)
leycec added a commit that referenced this pull request Jul 13, 2022
This commit is the next in a commit chain repairing currently failing
tests pertaining to the `beartype.door` subpackage, recently implemented
by Harvard microscopist and general genius @tlambert03 in PR #136.
Specifically, this commit:

* Resolves all pending `pyright` complaints concerning the
  `beartype.door` subpackage. Thankfully, `pyright` now *finally* passes
  across all supported Python versions.
* Extricates the `HINT_SIGNS_ORIGIN_ISINSTANCEABLE_ARGS_*` family of
  sign sets into the more suitable
  `beartype._data.hint.pep.sign.datapepsignset` submodule.
* Extricates the unit test exercising those sets into a more suitable
  new
  `beartype_test.a00_util.a00_data.hint.pep.sign.test_datapepsignset`
  test submodule.

(*Suitable suit table!*)
@leycec
Copy link
Member

leycec commented Jul 14, 2022

API banging continues. I'd just like to reiterate, @tlambert03, that your work here was sufficiently phenomenal that it convinced someone they were wrong. That someone is me. How often does that happen irl? Less often than we all would like.

So, what was I wrong about? I was wrong to neglect the object-oriented approach. Instead, I implemented @beartype 100% imperatively. Sure, there are a handful of dataclasses percolating about – but the code generation API at the heart of @beartype is almost purely procedural.

That's good, in the sense that that's as wickedly performant as pure-Python can be. But that's also bad, in the sense that that's increasingly unusable, unreadable, and unmaintainable. I'll take a maintainable decorator over a performant decorator.

The Future Beckons with a Creepy White Glove

Enter your API, stage left. I'm dumbfounded at how blatantly superior this API is to everything else I invented. It's Pythonic, it's usable, it's readable, it's maintainable, and it's on the cusp of just working.

What I'm trying to say here is that @beartype will eventually replace everything it internally does with this API instead. To do so, I'll be slowly building out support for efficient O(1) runtime type-checking directly in the beartype.door.TypeHint class and subclasses thereof.

This will probably take a year or two. But I'm committed to making this happen, because this has to happen. Sanity demands a sane API. Thus spake the Bear. 🐻

leycec added a commit that referenced this pull request Jul 14, 2022
This commit is the next in a commit chain repairing currently failing
tests pertaining to the `beartype.door` subpackage, recently implemented
by Harvard microscopist and general genius @tlambert03 in PR #136.
Specifically, this commit trivially documents various internal
refactorings to be subsequently applied to our private
`beartype.door._doorclass._TypeHintCallable` subclass. In other words,
we did nothing meaningful and have no regrets.
(*Lucky pluckiness nestled in a bested duck!*)
@tlambert03
Copy link
Contributor Author

wowie! that's big news. I'm glad to hear you're finding some inspiration leaking out of this PR into other areas.

I agree, this pattern did end up feeling maintainable and relatively easy to reason about (breaking up the recursion into little digestible bits). ... and I guess it's not a huge leap to just kinda add an is_instance concept to a TypeHint for runtime checking? That would indeed be a nice API.

Performance is nothing to shrug off though, so I look forward to seeing what your uncompromising nature comes up with as an ideal marriage of the two :)

@tlambert03
Copy link
Contributor Author

Catch! 🏀

1XLu

sorry I didn't get to this quickly. I appreciate and welcome the ping though! keep 'em coming, just had a busy week.
is 3.8 still failing? Or did you conquer this already?

@leycec
Copy link
Member

leycec commented Jul 14, 2022

Sadness ensues, because Python 3.7 and 3.8 are still failing. I'm currently re-enabling each test in the beartype_test.a00_unit.a60_api.door.door_test submodule one-by-one, repairing those tests that can be trivially resolved to support Python 3.7 and 3.8 and conditionally skipping the rest with @skip_if_python_version_less_than('3.9.0').

If you find a spare weekend between this and your other open-source commitments, napari 😮 napari it'd be super-swell if you could investigate the test_is_subhint() and test_typehint_equals() tests. We're skipping both with @skip_if_python_version_less_than('3.9.0') at the moment, as I couldn't quite grok what was happening there.

But at least pyright has been manhandled into submission! We give thanks to the eternal QA diety, Pacha Lubre, for his oversight in this matter.

@tlambert03
Copy link
Contributor Author

will take a look!
by the way, can you give me a quick rundown on the beartype policy with typing_extensions? (apologies, I'm sure this must be described somewhere, but I couldn't find it after a few searchers). It seems to generally be the case that you avoid supporting/testing backported types on earlier python versions, yes? So, if I run into a typing object that wasn't introduced until some version, I shouldn't try to test the typing_extensions backport?

@leycec
Copy link
Member

leycec commented Jul 16, 2022

...heh. Our policy is simple. Exactly as you surmise, we gratefully appreciate the existence of typing_extensions while yet loathing the runtime implementation of typing_extensions. @beartype only adds explicit support for specific typing_extensions type hints when someone suddenly opens up an issue heckling us about our lack of said support.

Currently, this means:

  • typing_extensions.Annotated, which we must support. If we didn't, the sad subset of our userbase still locked into Python 3.7 and 3.8 would be unable to use @beartype validators. </gasp>
  • typing_extensions.Literal, because the friendly graspologic guys at Microsoft really wanted us to do that. We gritted teeth and quietly did that. We do this for you, Microsoft! You're welcome.

All other typing_extensions attributes are mostly unsupported. They might work; they might not work. Usually more the latter than the former. Praise be to RNG Jesus.

scary jeebus

Let us now explain why. Historically, PEP authors have tended to trial-run their unfinished beta implementations of upcoming type hint factories (under peer review by the python-typing mailing list community) within typing_extensions itself. Once a PEP is accepted, those unfinished beta implementations never get updated within typing_extensions. They stagnate like derelict shambolic zombie APIs.

We see where this is going. It's pure anarchy in the streets! Most of typing_extensions either doesn't work at runtime or sorta works but in a completely non-standard way that's broken af from the perspective of runtime standards compliance. In other words...

So, if I run into a typing object that wasn't introduced until some version, I shouldn't try to test the typing_extensions backport?

Please God(s), don't test the typing_extensions backport. 😅

Oh – and thanks so much for whatever generous assistance you're able to render. Your time is valuable. Your plate is full. We happily accept whatever you hurl at us!

leycec added a commit that referenced this pull request Jul 16, 2022
This commit is the next in a commit chain repairing currently failing
tests pertaining to the `beartype.door` subpackage, recently implemented
by Harvard microscopist and general genius @tlambert03 in PR #136.
Specifically, this commit trivially documents yet more internal
refactorings to be subsequently applied to our private
`beartype.door._doorclass._TypeHintCallable` subclass. In other words,
we still did nothing meaningful and still have no regrets.
(*Fair-weather leather-feathered friends!*)
@tlambert03
Copy link
Contributor Author

tlambert03 commented Jul 16, 2022

ok, the issue with python 3.7 and 3.8 appears is somewhat related to a difference between from typing.get_args and utilpepget.get_hint_pep_args... but it's also somewhat related to our current lack of support/implementation for TypeVar in _doorcls.TypeHint

It appears that back in 37 and 38, the default argument to a bare, unparametrized generic like typing.List was an instance of TypeVar. In py39 and later, they did away with passing default params to _GenericAlias, and instead started passing nparams instead. They handled this internally with typing.get_args, such that if you use typing.get_args(typing.List) you get an empty tuple in all versions of python that had get_args (≥3.8). However, beartype goes straight for the __args__ attribute, and doesn't check if it's a special GenericAlias, so on py≤3.8 you'll get get_hint_pep_args(List) == (~T,) ... but on ≥ 3.9 you'll get get_hint_pep_args(List) == ()

what it means for us

I can make all tests in test_door.py pass in both py37 and py38 just by editing

def get_hint_pep_args(hint: object) -> tuple:
# Return the value of the "__args__" dunder attribute if this hint
# defines this attribute *OR* the empty tuple otherwise.
return getattr(hint, '__args__', ())

to read as:

        if getattr(hint, '_special', False):
            return ()
        return getattr(hint, '__args__', ())

unfortunately, that then breaks some of your other tests test_a00_utilpepget.py::test_get_hint_pep_args, because it's expecting anything with hint_pep_meta.is_args to have arguments.

I'm not really sure how you want to fix this one... here's the options I see:

  1. in doorcls, we just act like TypeVars aren't even there for now... and treat List[TypeVar("T")] exactly the same as List

    • outside of this being a strictly temporary fix, I don't really like it, since it's ignoring the root cause. Furthermore, once we do support TypeVar, we're gonna be right back up against this problem
  2. deal with the root cause in get_hint_pep_args. We kinda need to know if something is an unparametrized GenericAlias vs a parametrized one, and currently, in python 3.8 and less, we can't

    In [4]: get_hint_pep_args(typing.List)
    Out[4]: (~T,)
    
    In [5]: get_hint_pep_args(typing.List[typing.TypeVar("T")])
    Out[5]: (~T,)

    note though, typing knows:

    In [7]: get_args(typing.List)
    Out[7]: ()
    
    In [8]: get_args(typing.List[typing.TypeVar("T")])
    Out[8]: (~T,)

    I think I need you for this one though, since I'm not sure how you want to deal with the failing test and hint_pep_meta.is_args

  3. just leave these tests skipped until we fully implement support for TypeVar. Since an unbound TypeVar is, I guess, the same as Any, we don't really need to know whether we're looking at a parametrized vs unparametrized generic... but we do need to otherwise fully analyze the bounds of the TypeVar. That's a lot of work for a little problem here... but ultimately, we should tackle type var.

thoughts?

@leycec
Copy link
Member

leycec commented Jul 16, 2022

Breathtaking writeup! You continue to both stun and impress. The answer, of course, is 2. Right? You knew that was coming. @beartype is here to do things semantically right, even when that means doing things syntactically wrong. get_hint_pep_args() already lies about gently massages a few syntactic things for the greater good of semantic correctness. Let's keep doing that.

Moreover, we really have to keep doing that. I'd never actually noticed that our current approach prevented us from distinguishing an unsubscripted List from a parametrized List[TypeVar('T')]. But... apparently, it does. That's horrifying! Even our balding Maine Coon is appalled.

Thankfully, you even solved that the Right Way™ too. Let's temporarily break tests by implementing your proposed solution ala:

# In "utilpegget":

...
# Else, the active Python interpreter targets Python < 3.9. In this case,
# implement this getter to directly access the "__args__" dunder attribute.
else:
    def get_hint_pep_args(hint: object) -> tuple:

        # If this hint is unsubscripted (e.g., a bare "typing.List" reference),
        # return the empty tuple. Note this edge case is:
        # * Detected and handled by the standard "typing" implementation
        #   under Python 3.7 and 3.8 in the same exact way.
        # * Required to differentiate unsubscripted hints from parametrized
        #   hints (e.g., "List[TypeVar('T')]"). Why? Because the "typing"
        #   module nonsensically implements unsubscripted hints as
        #   parametrized hints under Python 3.7 and 3.8. The only safe
        #   means of disambiguating these two common cases is to
        #   violate privacy encapsulation by detecting this instance variable
        #   set to "False" for unsubscripted but *NOT* parametrized hints.
        if getattr(hint, '_special', False):
            return ()
        # Else, this hint is subscripted by one or more child type hints...
        # probably. But nothing is certain. We remain vigilant yet fearful.

        # Return the value of the "__args__" dunder attribute if this hint
        # defines this attribute *OR* the empty tuple otherwise.
        return getattr(hint, '__args__', ())

Does that sound reasonable? Honestly, I can't thank you enough – but I will anyway. 👏 🥳 👏

@leycec
Copy link
Member

leycec commented Jul 16, 2022

Callooh! Callay! I'm fairly sure (but technically uncertain) that tests that fail after applying your wondrous fix can be trivially repaired by:

  • In beartype_test.a00_unit.a60_api.data_pep484:
    # Let's change this line...
    _IS_ARGS_HIDDEN = _IS_TYPEVARS_HIDDEN

    # ...to resemble this instead. Sanity, we restore thee.
    _IS_ARGS_HIDDEN = False

That's it. I think? Let's see how far the rabbit hole goes. 🕳️ 🐇

leycec pushed a commit that referenced this pull request Jul 19, 2022
This commit by Harvard microscopist and general genius @tlambert03 is
the next in a commit chain repairing currently failing tests pertaining
to the `beartype.door` subpackage, recently implemented by... yup, it's
@tlambert03 strikes again in PR #136. Specifically, this commit improves
coverage by extending unit tests for this subpackage to additionally
test PEP 484- and 585-compliant `{collections.abc,typing}.Type[...]`
type hints against the `beartype.door.TypeHint` API. (*Long-lasting bold holdfast!*)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Feature Request] New beartype.math API for performing type hint arithmetic
2 participants