Skip to content

gh-106046: Improve error message from os.fspath if __fspath__ is set to None #106082

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

Merged
merged 5 commits into from
Jun 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Doc/reference/datamodel.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3179,8 +3179,9 @@ An example of an asynchronous context manager class::
lead to some very strange behaviour if it is handled incorrectly.

.. [#] The :meth:`~object.__hash__`, :meth:`~object.__iter__`,
:meth:`~object.__reversed__`, and :meth:`~object.__contains__` methods have
special handling for this; others
:meth:`~object.__reversed__`, :meth:`~object.__contains__`,
:meth:`~object.__class_getitem__` and :meth:`~os.PathLike.__fspath__`
methods have special handling for this. Others
will still raise a :exc:`TypeError`, but may do so by relying on
the behavior that ``None`` is not callable.

Expand Down
6 changes: 6 additions & 0 deletions Lib/os.py
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,12 @@ def _fspath(path):
else:
raise TypeError("expected str, bytes or os.PathLike object, "
"not " + path_type.__name__)
except TypeError:
if path_type.__fspath__ is None:
raise TypeError("expected str, bytes or os.PathLike object, "
"not " + path_type.__name__) from None
else:
raise
if isinstance(path_repr, (str, bytes)):
return path_repr
else:
Expand Down
39 changes: 39 additions & 0 deletions Lib/test/test_os.py
Original file line number Diff line number Diff line change
Expand Up @@ -4647,6 +4647,45 @@ def __fspath__(self):
return ''
self.assertFalse(hasattr(A(), '__dict__'))

def test_fspath_set_to_None(self):
class Foo:
__fspath__ = None

class Bar:
def __fspath__(self):
return 'bar'

class Baz(Bar):
__fspath__ = None

good_error_msg = (
r"expected str, bytes or os.PathLike object, not {}".format
)

with self.assertRaisesRegex(TypeError, good_error_msg("Foo")):
self.fspath(Foo())

self.assertEqual(self.fspath(Bar()), 'bar')

with self.assertRaisesRegex(TypeError, good_error_msg("Baz")):
self.fspath(Baz())

with self.assertRaisesRegex(TypeError, good_error_msg("Foo")):
open(Foo())

with self.assertRaisesRegex(TypeError, good_error_msg("Baz")):
open(Baz())

other_good_error_msg = (
r"should be string, bytes or os.PathLike, not {}".format
)

with self.assertRaisesRegex(TypeError, other_good_error_msg("Foo")):
os.rename(Foo(), "foooo")

with self.assertRaisesRegex(TypeError, other_good_error_msg("Baz")):
os.rename(Baz(), "bazzz")

class TimesTests(unittest.TestCase):
def test_times(self):
times = os.times()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Improve the error message from :func:`os.fspath` if called on an object
where ``__fspath__`` is set to ``None``. Patch by Alex Waygood.
4 changes: 2 additions & 2 deletions Modules/posixmodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -1197,7 +1197,7 @@ path_converter(PyObject *o, void *p)
PyObject *func, *res;

func = _PyObject_LookupSpecial(o, &_Py_ID(__fspath__));
if (NULL == func) {
if ((NULL == func) || (func == Py_None)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few thoughts, no change requested:

  • The parentheses are technically redundant, but it's OK to keep them for extra clarity.
  • The NULL == func here is a "Yoda condition". People write that because C lets you write if (func = NULL), and it doesn't do what you want (it always sets func to NULL). The Yoda condition prevents this, because if (NULL = func) is probably a syntax error. However, Yoda conditions aren't that useful these days as compilers will warn about if (func = NULL). Arguably you're introducing an inconsistency here as one of the two conditions on this line is Yoda and the other isn't, but I think that's fine.
  • There is a Py_IsNone function (https://docs.python.org/3/c-api/structures.html#c.Py_IsNone) that could be used instead of == Py_None. I think the motivation is to allow alternate C APIs where Py_None might not be a singleton. I don't know if there is any push to use this function in CPython itself, though I see a few calls to it. However, there are already several == Py_None checks in this file, so it seems fine to add a few more.
  • I already mentioned this on Discord, but before 3.12 this would have introduced a refleak, as the branch never decrefs func. However, now Py_None is immortal and we no longer have to worry about refcounting for it. Otherwise, I'd have suggested putting Py_XDECREF(func) inside the conditional block (the X meaning that func may be NULL).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • The NULL == func here is a "Yoda condition". People write that because C lets you write if (func = NULL), and it doesn't do what you want (it always sets func to NULL).

Thanks for explaining this! I wondered what the rationale was here for writing it like this!

Huh. I checked the API docs for Py_None before filing this PR, and they didn't mention this function — they recommended using ==: https://docs.python.org/3/c-api/none.html. I guess I'll leave this as it is for now, since as you say, there are a bunch of other places in this file that use == :)

goto error_format;
}
res = _PyObject_CallNoArgs(func);
Expand Down Expand Up @@ -15430,7 +15430,7 @@ PyOS_FSPath(PyObject *path)
}

func = _PyObject_LookupSpecial(path, &_Py_ID(__fspath__));
if (NULL == func) {
if ((NULL == func) || (func == Py_None)) {
return PyErr_Format(PyExc_TypeError,
"expected str, bytes or os.PathLike object, "
"not %.200s",
Expand Down