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

functools.total_ordering doesn't work with metaclasses #88771

Closed
glyph mannequin opened this issue Jul 12, 2021 · 14 comments
Closed

functools.total_ordering doesn't work with metaclasses #88771

glyph mannequin opened this issue Jul 12, 2021 · 14 comments
Assignees
Labels
3.9 only security fixes 3.10 only security fixes 3.11 only security fixes stdlib Python modules in the Lib dir type-bug An unexpected behavior, bug, or error

Comments

@glyph
Copy link
Mannequin

glyph mannequin commented Jul 12, 2021

BPO 44605
Nosy @rhettinger, @glyph, @ambv, @miss-islington, @sweeneyde
PRs
  • bpo-44605: Teach @total_ordering() to work with metaclasses #27633
  • [3.10] bpo-44605: Teach @total_ordering() to work with metaclasses (GH-27633) #27640
  • [3.9] bpo-44605: Teach @total_ordering() to work with metaclasses (GH-27633) #27641
  • Note: these values reflect the state of the issue at the time it was migrated and might not reflect the current state.

    Show more details

    GitHub fields:

    assignee = 'https://github.com/rhettinger'
    closed_at = <Date 2021-08-06.20:14:15.167>
    created_at = <Date 2021-07-12.05:21:04.983>
    labels = ['type-bug', 'library', '3.9', '3.10', '3.11']
    title = "functools.total_ordering doesn't work with metaclasses"
    updated_at = <Date 2021-08-06.20:14:15.166>
    user = 'https://github.com/glyph'

    bugs.python.org fields:

    activity = <Date 2021-08-06.20:14:15.166>
    actor = 'rhettinger'
    assignee = 'rhettinger'
    closed = True
    closed_date = <Date 2021-08-06.20:14:15.167>
    closer = 'rhettinger'
    components = ['Library (Lib)']
    creation = <Date 2021-07-12.05:21:04.983>
    creator = 'glyph'
    dependencies = []
    files = []
    hgrepos = []
    issue_num = 44605
    keywords = ['patch']
    message_count = 14.0
    messages = ['397278', '397283', '397350', '398187', '399023', '399028', '399048', '399053', '399056', '399058', '399112', '399134', '399136', '399138']
    nosy_count = 5.0
    nosy_names = ['rhettinger', 'glyph', 'lukasz.langa', 'miss-islington', 'Dennis Sweeney']
    pr_nums = ['27633', '27640', '27641']
    priority = 'normal'
    resolution = 'fixed'
    stage = 'resolved'
    status = 'closed'
    superseder = None
    type = 'behavior'
    url = 'https://bugs.python.org/issue44605'
    versions = ['Python 3.9', 'Python 3.10', 'Python 3.11']

    @glyph
    Copy link
    Mannequin Author

    glyph mannequin commented Jul 12, 2021

    Consider the following example that attempts to make a series of types sortable by their class name:

    from functools import total_ordering
    
    @total_ordering
    class SortableMeta(type):
        def __new__(cls, name, bases, ns):
            return super().__new__(cls, name, bases, ns)
    
        def __lt__(self, other):
            if not isinstance(other, SortableMeta):
                pass
            return self.__name__ < other.__name__
    
        def __eq__(self, other):
            if not isinstance(other, SortableMeta):
                pass
            return self.__name__ == other.__name__
    
    class B(metaclass=SortableMeta):
        pass
    
    class A(metaclass=SortableMeta):
        pass
    
    
    print(A < B)
    print(A > B)

    This should just print "True", "False", but instead the second comparison raises this exception:

    Traceback (most recent call last):
      File "total_ordering_metaclass.py", line 27, in <module>
        print(A > B)
      File "lib/python3.9/functools.py", line 91, in _gt_from_lt
        op_result = self.__lt__(other)
    TypeError: expected 1 argument, got 0

    The problem here is that functools considers .__special__ to be equivalent to operator.special. I'm pretty sure this should be invoking "self < other" rather than self.__lt__(other).

    @glyph glyph mannequin added 3.7 (EOL) end of life 3.8 only security fixes 3.9 only security fixes stdlib Python modules in the Lib dir type-bug An unexpected behavior, bug, or error labels Jul 12, 2021
    @glyph
    Copy link
    Mannequin Author

    glyph mannequin commented Jul 12, 2021

    Adding versions after confirming the bug is the same on 3.10

    @glyph glyph mannequin added 3.10 only security fixes 3.11 only security fixes labels Jul 12, 2021
    @sweeneyde
    Copy link
    Member

    The one twist is that if type(b) is a strict subtype of type(a), then "a < b" first calls type(b).__gt__(b, a), then falls back to type(a).__lt__(a, b). Example:

    >>> class Int(int):
    ...     def __gt__(self, other):
    ...         print("Here!")
    ...         return int(self) > int(other)
    ... 
    ...     
    >>> 5 < Int(6)
    Here!
    True

    see

    if (!Py_IS_TYPE(v, Py_TYPE(w)) &&

    So I think replacing "a.__lt__(b)" with "a < b" would be an unnecessary change in behavior, since "a < b" still has to decide which method to use.

    I think instead "a.__lt__(b)" could be replaced with "type(a).__lt__(a, b)"

    @rhettinger rhettinger removed 3.7 (EOL) end of life 3.8 only security fixes 3.9 only security fixes 3.10 only security fixes labels Jul 15, 2021
    @rhettinger
    Copy link
    Contributor

    To add support for metaclasses, replacing 'self.__lt__(other)' with 'type(self).__lt__(self, other)' will work.

    The performance degradation for the common case is about 25%:

    $ py -m timeit -s 'from tmp import a, b' 'a >= b'     # op_result = type(self).__lt__(self, other)
    1000000 loops, best of 5: 283 nsec per loop
    
    $ py -m timeit -s 'from tmp import a, b' 'a >= b'     # op_result = self.__lt__(other)
    1000000 loops, best of 5: 227 nsec per loop

    @rhettinger
    Copy link
    Contributor

    Do you all have preference for 1) expanding the range of use cases to cover metaclasses but incurring a performance hit for common cases, or 2) leaving it as-is and documenting that it doesn't work for metaclasses?

    @glyph
    Copy link
    Mannequin Author

    glyph mannequin commented Aug 5, 2021

    Do you all have preference for 1) expanding the range of use cases to cover metaclasses but incurring a performance hit for common cases, or 2) leaving it as-is and documenting that it doesn't work for metaclasses?

    If we do need the slow approach for the meta-type case, (i.e.: type(self).__lt__(other)) then why not decide on implementation at decoration time? The decorator has enough information to know if this strategy is necessary and can put different special methods in place.

    (But also, wouldn't 'from operator import lt ... lt(self, other)' be a bit faster, and also more general, just because it's doing specialized dispatch in C rather than an additional Python function call / method dispatch? I have not benchmarked myself, so please ignore if you've already tested this.)

    @rhettinger
    Copy link
    Contributor

    [Glyph]

    why not decide on implementation at decoration time?

    We could do that and not incur performance issues. However, it would expand the API and double the size of the code.

    We've had no other reports about this issue since total_ordering() was added in Python 2.7, nor have there been any questions about it on StackOverflow. Do you think anyone other than yourself would use this feature?

    It seems like a rare niche use case, and the workaround is simple.

    For production code, I usually recommend writing out the other three methods (that is less magical, runs faster, easier to test, has fewer dependencies, and makes the tracebacks more readable.

    (But also, wouldn't 'from operator import lt ... lt(self, other)'
    be a bit faster, and also more general,

    The lt() function emulates the '<' operator, so we still have the problems with reflection that we were trying to avoid by calling the __lt__ method directly.

    @glyph
    Copy link
    Mannequin Author

    glyph mannequin commented Aug 6, 2021

    We could do that and not incur performance issues. However, it would expand the API and double the size of the code.

    If the preference is to not support this use-case, then I'd rather the decorator simply raise an exception and tell me this is going to be a problem so I can eagerly implement the workaround.

    It seems like a rare niche use case, and the workaround is simple.

    It does seem like there are various times that one might want to make classes orderable, for example if they are bound to IDL objects or database tables; the use-case where I ran into this was something like that.

    For production code, I usually recommend writing out the other three methods (that is less magical, runs faster, easier to test, has fewer dependencies, and makes the tracebacks more readable.

    Wait, is the suggestion here that @total_ordering is not suitable for production?

    @rhettinger
    Copy link
    Contributor

    If the preference is to not support this use-case ,,,

    I don't really have a preference. Was just letting you know the pros and cons. I'll put together a PR with the "type(self).__lt__(self, other)" substitutions. Let me know if that is the outcome you want.

    Wait, is the suggestion here that @total_ordering
    is not suitable for production?

    It works fine, but as noted in the docs, the indirect code isn't as good as just writing-out the methods by hand.

    @glyph
    Copy link
    Mannequin Author

    glyph mannequin commented Aug 6, 2021

    My preference would be to fix it one way or another; the stdlib should be correct before it's performant. If, as you say, it's better to write the methods longhand anyway for ease of debugging, then it makes sense to write them out for performance, too.

    @ambv
    Copy link
    Contributor

    ambv commented Aug 6, 2021

    IMO the PR should be accepted and have approved it accordingly.

    @total_ordering, as mentioned by Raymond, wasn't ever the most efficient way to implement ordering on a class. It is the most readable and easy way to get a correct implementation though so it's preferred where performance isn't the highest priority.

    @rhettinger
    Copy link
    Contributor

    New changeset 1f7d646 by Raymond Hettinger in branch 'main':
    bpo-44605: Teach @total_ordering() to work with metaclasses (GH-27633)
    1f7d646

    @rhettinger
    Copy link
    Contributor

    New changeset fde8417 by Miss Islington (bot) in branch '3.9':
    bpo-44605: Teach @total_ordering() to work with metaclasses (GH-27633) (GH-27641)
    fde8417

    @rhettinger
    Copy link
    Contributor

    New changeset 66dd1a0 by Miss Islington (bot) in branch '3.10':
    bpo-44605: Teach @total_ordering() to work with metaclasses (GH-27633) (GH-27640)
    66dd1a0

    @rhettinger rhettinger added 3.9 only security fixes 3.10 only security fixes labels Aug 6, 2021
    @rhettinger rhettinger added 3.9 only security fixes 3.10 only security fixes labels Aug 6, 2021
    @ezio-melotti ezio-melotti transferred this issue from another repository Apr 10, 2022
    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
    Labels
    3.9 only security fixes 3.10 only security fixes 3.11 only security fixes stdlib Python modules in the Lib dir type-bug An unexpected behavior, bug, or error
    Projects
    None yet
    Development

    No branches or pull requests

    3 participants