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

GH-64978: Add pathlib.Path.chown() #104183

Closed
Closed
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
15 changes: 15 additions & 0 deletions Doc/library/pathlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,19 @@ call fails (for example because the path doesn't exist).
.. versionchanged:: 3.10
The *follow_symlinks* parameter was added.

.. method:: Path.chown(owner=None, group=None)

Change the *owner* and/or *group* of the path.

*owner* can be a system user name or a uid; the same applies to *group*.
At least one argument is required.

.. audit-event:: pathlib.Path.chown path,owner,group pathlib.Path.chown

.. availability:: Unix.

.. versionadded:: 3.12
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess 3.12's already shipped 😅

Suggested change
.. versionadded:: 3.12
.. versionadded:: 3.13


.. method:: Path.exists(*, follow_symlinks=True)

Return ``True`` if the path points to an existing file or directory.
Expand Down Expand Up @@ -1466,6 +1479,7 @@ Below is a table mapping various :mod:`os` functions to their corresponding
:func:`os.path.abspath` :meth:`Path.absolute` [#]_
:func:`os.path.realpath` :meth:`Path.resolve`
:func:`os.chmod` :meth:`Path.chmod`
:func:`os.chown` :meth:`Path.chown` [#]_
:func:`os.mkdir` :meth:`Path.mkdir`
:func:`os.makedirs` :meth:`Path.mkdir`
:func:`os.rename` :meth:`Path.rename`
Expand Down Expand Up @@ -1500,4 +1514,5 @@ Below is a table mapping various :mod:`os` functions to their corresponding
.. rubric:: Footnotes

.. [#] :func:`os.path.abspath` normalizes the resulting path, which may change its meaning in the presence of symlinks, while :meth:`Path.absolute` does not.
.. [#] :meth:`Path.chown` supports owner and group names and IDs, whereas :func:`os.chown` only supports IDs.
.. [#] :meth:`PurePath.relative_to` requires ``self`` to be the subpath of the argument, but :func:`os.path.relpath` does not.
35 changes: 33 additions & 2 deletions Lib/pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from _collections_abc import Sequence
from errno import ENOENT, ENOTDIR, EBADF, ELOOP
from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO
from urllib.parse import quote_from_bytes as urlquote_from_bytes


__all__ = [
Expand Down Expand Up @@ -410,7 +409,8 @@ def as_uri(self):
# It's a posix path => 'file:///etc/hosts'
prefix = 'file://'
path = str(self)
return prefix + urlquote_from_bytes(os.fsencode(path))
from urllib.parse import quote_from_bytes
return prefix + quote_from_bytes(os.fsencode(path))

@property
def _str_normcase(self):
Expand Down Expand Up @@ -1241,6 +1241,37 @@ def lchmod(self, mode):
"""
self.chmod(mode, follow_symlinks=False)

if hasattr(os, 'chown'):
def chown(self, owner=None, group=None):
"""Change the owner and/or group of the path.

owner and group can be the uid/gid or the user/group names, and
in that case, they are converted to their respective uid/gid.
"""
sys.audit('pathlib.Path.chown', self, owner, group)

if owner is None:
if group is None:
raise ValueError("user and/or group must be set")
owner = -1
elif isinstance(owner, str):
try:
import pwd
owner = pwd.getpwnam(owner)[2]
except (ImportError, KeyError):
raise LookupError(f"no such user: {owner!r}") from None

if group is None:
group = -1
elif isinstance(group, str):
try:
import grp
group = grp.getgrnam(group)[2]
except (ImportError, KeyError):
raise LookupError(f"no such group: {group!r}") from None

os.chown(self, owner, group)

def unlink(self, missing_ok=False):
"""
Remove this file or link.
Expand Down
32 changes: 9 additions & 23 deletions Lib/shutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import fnmatch
import collections
import errno
import pathlib
import warnings

try:
Expand Down Expand Up @@ -1381,29 +1382,14 @@ def chown(path, user=None, group=None):
"""
sys.audit('shutil.chown', path, user, group)

if user is None and group is None:
raise ValueError("user and/or group must be set")

_user = user
_group = group

# -1 means don't change it
if user is None:
_user = -1
# user can either be an int (the uid) or a string (the system username)
elif isinstance(user, str):
_user = _get_uid(user)
if _user is None:
raise LookupError("no such user: {!r}".format(user))

if group is None:
_group = -1
elif not isinstance(group, int):
_group = _get_gid(group)
if _group is None:
raise LookupError("no such group: {!r}".format(group))

os.chown(path, _user, _group)
if not isinstance(path, pathlib.Path):
path = os.fspath(path)
if isinstance(path, bytes):
path = os.fsdecode(path)
path = pathlib.Path(path)

path.chown(user, group)


def get_terminal_size(fallback=(80, 24)):
"""Get the size of the terminal window.
Expand Down
61 changes: 61 additions & 0 deletions Lib/test/test_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -3101,6 +3101,67 @@ def test_handling_bad_descriptor(self):
self.fail("Bad file descriptor not handled.")
raise

@unittest.skipUnless(pwd, "the pwd module is needed for this test")
@unittest.skipUnless(grp, "the grp module is needed for this test")
@unittest.skipUnless(hasattr(os, 'chown'), 'requires os.chown')
def test_chown(self):
dirname = self.cls(BASE)
filename = dirname / "fileA"

with self.assertRaises(ValueError):
filename.chown()

with self.assertRaises(LookupError):
filename.chown(owner='non-existing username')

with self.assertRaises(LookupError):
filename.chown(group='non-existing groupname')

with self.assertRaises(TypeError):
filename.chown(b'spam')

with self.assertRaises(TypeError):
filename.chown(3.14)

uid = os.getuid()
gid = os.getgid()

def check_chown(path, uid=None, gid=None):
s = path.stat()
if uid is not None:
self.assertEqual(uid, s.st_uid)
if gid is not None:
self.assertEqual(gid, s.st_gid)

filename.chown(uid, gid)
check_chown(filename, uid, gid)
filename.chown(uid)
check_chown(filename, uid)
filename.chown(owner=uid)
check_chown(filename, uid)
filename.chown(group=gid)
check_chown(filename, gid=gid)

dirname.chown(uid, gid)
check_chown(dirname, uid, gid)
dirname.chown(uid)
check_chown(dirname, uid)
dirname.chown(owner=uid)
check_chown(dirname, uid)
dirname.chown(group=gid)
check_chown(dirname, gid=gid)

try:
owner = pwd.getpwuid(uid)[0]
group = grp.getgrgid(gid)[0]
except KeyError:
# On some systems uid/gid cannot be resolved.
pass
else:
filename.chown(owner, group)
check_chown(filename, uid, gid)
dirname.chown(owner, group)
check_chown(dirname, uid, gid)

@only_nt
class WindowsPathTest(_BasePathTest, unittest.TestCase):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add :meth:`pathlib.Path.chown`, which changes the owner and/or group of a
path.