As suggested by Toshio Kuratomi at https://bugs.python.org/issue36656, I am raising this here for inclusion in the shutil module.

Mimicking POSIX, os.symlink() will raise FileExistsError if the link name to be created already exists.

A common use case is overwriting an existing file (often a symlink) with a symlink. Naively, one would delete the file named link_name file if it exists, then call symlink(). This "solution" is already 3 lines of code, and without exception handling it introduces the race condition of a file named link_name being created between unlink and symlink.

Depending on the functionality required, I suggest:

* os.symlink() - the new link name is expected to NOT exist
* shutil.symlink() - the new symlink replaces an existing file

Handling all possible race conditions (some detailed in issue36656) is non-trivial, however this is the best that I have come up with so far:

==========================================================================

import os, tempfile

def symlink(target, link_name):
    '''Create a symbolic link link_name pointing to target.
    Overwrites link_name if it exists. '''

    # os.replace() may fail if files are on different filesystems
    link_dir = os.path.dirname(link_name)

    # Link to a temporary filename that doesn't exist
    while True:
        temp_link_name = tempfile.mktemp(dir=link_dir)

        # os.* functions mimic as closely as possible system functions
        # The POSIX symlink() returns EEXIST if link_name already exists
# https://pubs.opengroup.org/onlinepubs/9699919799/functions/symlink.html
        try:
            os.symlink(target, temp_link_name)
            break
        except FileExistsError:
            pass

    # Replace link_name with temp_link_name
    try:
        # Pre-empt os.replace on a directory with a nicer message
        if os.path.isdir(link_name):
raise IsADirectoryError(f"Cannot symlink over existing directory: '{link_name}'")
        os.replace(temp_link_name, link_name)
    except:
        if os.path.islink(temp_link_name):
            os.remove(temp_link_name)
        raise

==========================================================================

The documentation (https://docs.python.org/3/library/shutil.html) I suggest for this is:

shutil.symlink(target, link_name)
Create a symbolic link named link_name pointing to target, overwriting target if it exists. If link_name is a directory, IsADirectoryError is raised. To not overwrite target, use os.symlink()

==========================================================================

It would be tempting to do:

while True:
    try:
        os.symlink(target, link_name)
        break
    except FileExistsError:
        os.remove(link_name)

But this has a race condition when replacing a symlink should should *always* exist, eg:

    /lib/critical.so -> /lib/critical.so.1.2

When upgrading by:

    symlink('/lib/critical.so.2.0', '/lib/critical.so')

There is a point in time when /lib/critical.so doesn't exist.

==========================================================================

One issue I see with my suggested code is that the file at temp_link_name could be changed before target is replaced with it. This is mitigated by the randomness introduced by mktemp().

While it is far less likely that a file is accessed with a random and unknown name than with an existing known name, I seek input on a solution if this is an unacceptable risk.


Prior art:

* https://bugs.python.org/issue36656 (already mentioned above)
* https://stackoverflow.com/a/55742015/5353461
* https://git.savannah.gnu.org/cgit/coreutils.git/tree/src/ln.c

--
Tom Hale
_______________________________________________
Python-ideas mailing list
Python-ideas@python.org
https://mail.python.org/mailman/listinfo/python-ideas
Code of Conduct: http://python.org/psf/codeofconduct/

Reply via email to