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

property assumes that its subclasses have __dict__ #98963

Closed
encukou opened this issue Nov 1, 2022 · 19 comments
Closed

property assumes that its subclasses have __dict__ #98963

encukou opened this issue Nov 1, 2022 · 19 comments
Assignees
Labels
3.12 bugs and security fixes topic-C-API type-bug An unexpected behavior, bug, or error

Comments

@encukou
Copy link
Member

encukou commented Nov 1, 2022

property's tp_init has this code:

    if (Py_IS_TYPE(self, &PyProperty_Type)) {
        Py_XSETREF(self->prop_doc, prop_doc);
    } else {
        /* If this is a property subclass, put __doc__
           in dict of the subclass instance instead,
           otherwise it gets shadowed by __doc__ in the
           class's dict. */

        /*...*/
         PyObject_SetAttr((PyObject *)self, &_Py_ID(__doc__), prop_doc);
        /*...*/
    }

This assumes that subclasses of property have a __dict__ (or a __doc__ attribute settable by other means). That is the case when subclassed using the class statement, but might not be true using C.
A C-API reproducer is at https://github.com/wjakob/inheritance_issue/blob/master/inheritance_issue.c

I don't see a good way to fix this. We could:

  • document that subclasses need to set Py_TPFLAGS_MANAGED_DICT (or provide a __doc__ descriptor), or
  • ignore the AttributeError, unless __doc__ was set explicitly?
@encukou encukou added type-bug An unexpected behavior, bug, or error topic-C-API labels Nov 1, 2022
@rhettinger
Copy link
Contributor

rhettinger commented Nov 2, 2022

Recommend ignoring the AttributeError. Loss of a __doc__ in this situation is of no consequence.

@encukou
Copy link
Member Author

encukou commented Nov 2, 2022

Thinking about it more, I came to the opposite conclusion.

Ignoring AttributeError moves the surprising behavior elsewhere – now people would wonder why doc isn't set as requested. (Worse, if the property subclass is defined in a library, it's likely that a user would bump into this, rather than the library author.)

The main issue here is that the error is surprising – it's not clear what's happening and what the right course of action is. If you're subclassing property in the C API, adding a __doc__ descriptor (or adding Py_TPFLAGS_MANAGED_DICT/tp_dictoffset) shouldn't be an issue – if you know that's what's necessary. (If losing __doc__ is OK, the descriptor can even ignore the data and always get None. Making it explicit sounds better in this case.)
So, another option is to just raise a better error – “property subclasses require a writable doc instance attribute”?

@wjakob
Copy link
Contributor

wjakob commented Nov 2, 2022

I also don't think that losing the __doc__ silently is a good behavior.

Would it not be an option to relax the condition at the top? Instead of Py_IS_TYPE(self, &PyProperty_Type), the test could spend some more time to see when it is safe to use PyObject_SetAttr. AFAIK that would be

  • If the type has an associated dictionary
  • If somewhere in the type hierarchy, we expose a writable __doc__ field via tp_members

wjakob added a commit to wjakob/nanobind that referenced this issue Nov 2, 2022
This commit ports a few improvements from the 'limited_api' branch
of nanobind. In particular

- 'nb_static_property' is constructed in a different way by exposing a
  writeable '__doc__' member field. This is to work around an issue in
  CPython (related discussion can be found here:
  python/cpython#98963)

- Instead of copy-pasting GC ``tp_clear`` and ``tp_traverse`` slots from
  parent to child classes, we rely on ``PyType_Ready`` doing this
  automatically.
@encukou
Copy link
Member Author

encukou commented Nov 2, 2022

If somewhere in the type hierarchy, we expose a writable __doc__ field via tp_members

property does that. But Python doesn't consider the superclass's descriptor (even when setting it) when there's a class attribute of the same name on the subclass. Here, that's the subclass's own docstring (None by default).

To make __doc__ work, it needs to either be a descriptor on the subclass, or an attribute on the instance.

@rhettinger
Copy link
Contributor

FWIW,. this code has been stable for 20 years. AFAICT, the only motivation for an edit is to accommodate single, new, and somewhat exotic C extension. So any proposed change should be minimally invasive.

Thinking about it more, I came to the opposite conclusion.

It doesn't feel like my assessment is being valued.

@encukou
Copy link
Member Author

encukou commented Nov 3, 2022

any proposed change should be minimally invasive.

I definitely agree with that! I hope to only touch the error case.

AFAICT, the only motivation for an edit is to accommodate single, new, and somewhat exotic C extension.

My opinion here is more nuanced though. The C API is not used only with C, and I've been trying to make it more useful with other languages (C++, Rust, Java). Language bindings work at a lower level than typical C extensions, so they run into subtle bugs we haven't seen before.
So, yes, it's a somewhat exotic C extension – but if we fix the issues it encounters, we'll hopefully get more extensions like it, and it'll become less exotic.

It doesn't feel like my assessment is being valued.

I'm sorry I made you feel this way. I tried to react to you and explain my reasoning. How can I do better?

@wjakob
Copy link
Contributor

wjakob commented Nov 3, 2022

For what it's worth, I understand @rhettinger's concern that this is somewhat of a niche issue. While making Python behave more as expected is desirable, I would already be very happy simply with guidance on how to work around this issue in a stable manner (meaning that it works for past Python versions, and that I can have some reasonable assurance it won't break in the future).

Alas, that requirement of stability is what is making things very difficult:

  • @encukou suggested that the subclass should expose a writeable __doc__ member. However, the behavior of such a combination is unstable across Python versions. A minimal C-API reproducer can be found here: https://github.com/wjakob/inheritance_issue_2/blob/master/inheritance_issue_2.c

    To try:

    $ pip install git+https://github.com/wjakob/inheritance_issue_2

    In Python 3.10

    >>> import inheritance_issue_2
    >>> print(inheritance_issue_2.my_property(lambda x: None, doc='test').__doc__)
    None

    In Python 3.12 (main)

    >>> import inheritance_issue_2
    >>> print(inheritance_issue_2.my_property(lambda x: None, doc='test').__doc__)
    test

    So the point by @rhettinger (this code has been stable for 20 years) does not seem to hold 100%. Something has subtly changed.

  • Py_TPFLAGS_MANAGED_DICT suggested by @encukou is not in the limited API, which I require at least as an optional build configuration. So that one is out.

  • Installing an unmanaged dictionary via __dictoffset__ is unstable across Python versions. A minimal C-API reproducer can be found here: https://github.com/wjakob/inheritance_issue_3/blob/master/inheritance_issue_3.c

    To try:

    $ pip install git+https://github.com/wjakob/inheritance_issue_3

    In Python 3.10

    >>> import inheritance_issue_3
    >>> print(inheritance_issue_3.my_property(lambda x: None, doc='test').__doc__)
    None

    In Python 3.12 (main)

    >>> import inheritance_issue_3
    >>> print(inheritance_issue_3.my_property(lambda x: None, doc='test').__doc__)
    test
  • The only thing I have discovered so far which consistently works across Python versions is to set the new property type's tp_members pointer to PyProperty_Type.tp_members. This can be done even in the limited API via PyType_GetSlot, but as @encukou pointed out this makes too many assumptions about the underlying implementation. So although it happens to compile and work with -DPy_LIMITED_API right now, it is not future-proof.

If you have advice on other things to try, I am all ears.. :)

encukou added a commit to encukou/cpython that referenced this issue Nov 3, 2022
…ut __doc__

Subclasses created using the C API don't get __dict__, which means their
__doc__ can't be set for each property separately. The __doc__
descriptor from `property` itself is shadowed by the subclass's docstring.

This edge case isn't really worth mentioning in the docs. This adds a note
that should guide anyone encountering the issue to the right fix.
@encukou
Copy link
Member Author

encukou commented Nov 3, 2022

Aha! It turns out that this was not stable -- in fact this was just changed for 3.12: #23205
(If anything, this underscores the point that touching old code should be done extremely carefully.)

@wjakob: There's even some pybind11 fallout: pybind/pybind11#4168

Given that silencing the AttributeError would essentially revert this case to pre-3.12 behavior, it would make sense. (Edit: no, the pre-3.12 behavior was more complicated: the error was raised or not depending on where the docstring came from.)
But, before I wrote this I already wrote a patch for improving the error message, so I'll post it here: #99058

@wjakob
Copy link
Contributor

wjakob commented Nov 3, 2022

Aha, it's great to know that v3.12 is the place where this change happened. Incidentally, this is also the first Python version for which -DPy_LIMITED_API will be complete enough for my project.

So what I am thinking to do now as a workaround is:

  • For Python < 3.12, set the new property's tp_members field to PyProperty_Type.tp_members.

  • For Python >= 3.12 and in particular limited API mode, declare an independent tp_members field that adds a __doc__ field. (That seems more economical than a full-blown dictionary)

The error message in PR #99058 looks good to me.

wjakob added a commit to wjakob/nanobind that referenced this issue Nov 3, 2022
The handling of the ``__doc__`` member of CPython's ``property`` type
changes in version 3.12. This commit changes the way in which this
type is created pre/post-v3.12.

See python/cpython#98963 for details.
@encukou
Copy link
Member Author

encukou commented Nov 3, 2022

Actually, the Python 3.12 change was for the doc argument – when the docstring is being copied from a setter function, instantiation does fail as far back as Python 2. Pre-3.12, the doc argument doesn't work on subclasses at all.

@wjakob
Copy link
Contributor

wjakob commented Nov 3, 2022

Are you referring to the doc argument of the constructor? This evening's latest version of nanobind passes it to a subclass of property using the two different strategies mentioned in my last message (implementation: wjakob/nanobind@e82f14a). It passes the test suite on Python 3.8-3.12, which checks that the docstring is set correctly.

The docstring is specified directly to the property constructor in this case, instead of being copied from a setter function.

Note: this commit does not yet use the features from your tentative PEP (negative basicsize etc, that is all in a different branch).

@gpshead
Copy link
Member

gpshead commented Jun 2, 2023

This came up in protobuf because it has a

class _FieldProperty(property):
  __slots__ = ("DESCRIPTOR",)

calling property.__init__() on such a class instance fails with an exception, no matter what args are passed, doc or not:

    property.__init__(self, getter, setter, doc=doc)
AttributeError: '_FieldProperty' object attribute '__doc__' is read-only

If "__doc__" is added to the __slots__ tuple, it works again.

In older versions of Python the the property constructor silently does not assign a docstring rather than causing an AttributeError.

@gpshead
Copy link
Member

gpshead commented Jun 2, 2023

That is narrower than the larger issue here, but I think we should mirror that silent doc-dropping behavior for 3.12 and consider if we're deprecating that behavior later or just want to make it official.

gpshead added a commit to gpshead/cpython that referenced this issue Jun 3, 2023
Ignore doc string assignment failures in `property` as has been the
behavior of all past Python releases.

One behavior change: The longstanding tested behavior of raising an
`AttributeError` when using a slotted no-dict property subclass as a
decorator on a getter sporting a docstring is now consistent with the
subclassing behavior and does not produce an error.
@gpshead
Copy link
Member

gpshead commented Jun 3, 2023

That is narrower than the larger issue here

re-reading things, I don't think that is true. This issue title talks about a dict, but the issue is entirely about a the docstring being unsettable and whether that is ignored as it always has been or is an expected behavior changing error.

Existing code depends on it being ignored.

@gpshead gpshead self-assigned this Jun 3, 2023
JelleZijlstra pushed a commit that referenced this issue Jun 5, 2023
Ignore doc string assignment failures in `property` as has been the
behavior of all past Python releases.
miss-islington pushed a commit to miss-islington/cpython that referenced this issue Jun 5, 2023
…thonGH-105262)

Ignore doc string assignment failures in `property` as has been the
behavior of all past Python releases.
(cherry picked from commit 418befd)

Co-authored-by: Gregory P. Smith <greg@krypto.org>
@gpshead
Copy link
Member

gpshead commented Jun 5, 2023

closing as the 3.12 PR is set to automerge; previous exception / ignored error behavior has been restored.

@gpshead gpshead closed this as completed Jun 5, 2023
gpshead added a commit that referenced this issue Jun 5, 2023
…H-105262) (#105297)

gh-98963: Restore the ability to have a dict-less property. (GH-105262)

Ignore doc string assignment failures in `property` as has been the
behavior of all past Python releases.  (the docstring is discarded)
(cherry picked from commit 418befd)

This fixes a behavior regression in 3.12beta1 where an AttributeError was being raised in a situation it has never been in the past. It keeps the existing unusual single situation where AttributeError does get raised.

Existing widely deployed projects depend on this not raising an exception.

Co-authored-by: Gregory P. Smith <greg@krypto.org>
@gpshead
Copy link
Member

gpshead commented Jun 7, 2023

Related to this in general... CPython <=3.11 has some seriously smelly behavior:

Python 3.10.9 (main, Dec  7 2022, 13:47:07) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> class slotted_property(property):
...   __slots__ = ()
... 
>>> slotted_property()
<__main__.slotted_property object at 0x7f36e189dad0>
>>> slotted_property(lambda x: None)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'slotted_property' object attribute '__doc__' is read-only
>>> slotted_property(lambda x: None, doc='ignored').__doc__
>>> slotted_property(lambda x: None, doc='').__doc__
>>> slotted_property(lambda x: None, doc=None).__doc__
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'slotted_property' object attribute '__doc__' is read-only
>>>

@gpshead
Copy link
Member

gpshead commented Jun 7, 2023

Those surprisingly inconsistent AttributeErrors are gone in 3.12. If we ever decide to bring an exception back in this case, we'll want to be fully consistent about it.

@encukou
Copy link
Member Author

encukou commented Jun 12, 2023

Thank you for taking this on!

@wjakob
Copy link
Contributor

wjakob commented Jun 12, 2023

A thanks from me as well. With your change, I was able to remove the Python 3.12-specific workaround/hack from nanobind.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
3.12 bugs and security fixes topic-C-API type-bug An unexpected behavior, bug, or error
Projects
None yet
Development

No branches or pull requests

4 participants