Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
35 changes: 35 additions & 0 deletions Lib/test/test_os.py
Original file line number Diff line number Diff line change
Expand Up @@ -4647,6 +4647,41 @@ 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)|(should be)) 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())

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

with self.assertRaisesRegex(TypeError, 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.
14 changes: 7 additions & 7 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 @@ -1271,11 +1271,11 @@ path_converter(PyObject *o, void *p)
path->function_name ? path->function_name : "",
path->function_name ? ": " : "",
path->argument_name ? path->argument_name : "path",
path->allow_fd && path->nullable ? "string, bytes, os.PathLike, "
"integer or None" :
path->allow_fd ? "string, bytes, os.PathLike or integer" :
path->nullable ? "string, bytes, os.PathLike or None" :
"string, bytes or os.PathLike",
path->allow_fd && path->nullable ? "str, bytes, os.PathLike, "
"int or None" :
path->allow_fd ? "str, bytes, os.PathLike or int" :
path->nullable ? "str, bytes, os.PathLike or None" :
"str, bytes or os.PathLike",
_PyType_Name(Py_TYPE(o)));
goto error_exit;
}
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