Repository: pallets/click

Issue:
open_file(…, 'w', atomic=True) sets or resets permissions to 600, ignoring umask
Files are normally created with permission `666 & umask` as expected:

```pycon
>>> click.open_file('/tmp/test1', 'w').close()
>>> oct(os.stat('/tmp/test1').st_mode)
'0o100644'
```

However, opening a new or existing file with `atomic=True` sets or resets its permissions to `600` unconditionally:

```python
>>> click.open_file('/tmp/test2', 'w', atomic=True).close()
>>> oct(os.stat('/tmp/test2').st_mode)
'0o100600'
>>> click.open_file('/tmp/test1', 'w', atomic=True).close()
>>> oct(os.stat('/tmp/test1').st_mode)
'0o100600'
```

I’d expect a new file created with `atomic=True` to be created with the usual permission `666 & umask`, and an existing file opened with `atomic=True` to have the previous permissions preserved. (There’s probably no way to preserve permissions atomically; taking the previous permissions at the time of the initial open is fine.)


Discussion:
Could you please tell me the operating system you working with??

Resolving patch:
diff --git a/click/_compat.py b/click/_compat.py
--- a/click/_compat.py
+++ b/click/_compat.py
@@ -504,9 +504,38 @@ def open_stream(filename, mode='r', encoding=None, errors='strict',
     # as a proxy in the same folder and then using the fdopen
     # functionality to wrap it in a Python file.  Then we wrap it in an
     # atomic file that moves the file over on close.
-    import tempfile
-    fd, tmp_filename = tempfile.mkstemp(dir=os.path.dirname(filename),
-                                        prefix='.__atomic-write')
+    import errno
+    import random
+
+    try:
+        perm = os.stat(filename).st_mode
+    except OSError:
+        perm = None
+
+    flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
+    if 'b' in mode:
+        flags |= getattr(os, 'O_BINARY', 0)
+
+    while True:
+        tmp_filename = os.path.join(
+            os.path.dirname(filename),
+            '.__atomic-write%08x' % (random.randrange(1 << 32),),
+        )
+        try:
+            fd = os.open(tmp_filename, flags, 0o666 if perm is None else perm)
+            break
+        except OSError as e:
+            if e.errno == errno.EEXIST or (
+                os.name == "nt"
+                and e.errno == errno.EACCES
+                and os.path.isdir(dir)
+                and os.access(dir, os.W_OK)
+            ):
+                continue
+            raise
+
+    if perm is not None:
+        os.chmod(tmp_filename, perm)  # in case perm includes bits in umask
 
     if encoding is not None:
         f = io.open(fd, mode, encoding=encoding, errors=errors)


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,4 +1,5 @@
 import os
+import stat
 import sys
 
 import pytest
@@ -298,6 +299,48 @@ def cli(filename):
         assert result.output == 'foobar\nmeep\n'
 
 
+@pytest.mark.skipif(WIN, reason='os.chmod() is not fully supported on Windows.')
+@pytest.mark.parametrize('permissions', [
+    0o400,
+    0o444,
+    0o600,
+    0o644,
+ ])
+def test_open_file_atomic_permissions_existing_file(runner, permissions):
+    with runner.isolated_filesystem():
+        with open('existing.txt', 'w') as f:
+            f.write('content')
+        os.chmod('existing.txt', permissions)
+
+        @click.command()
+        @click.argument('filename')
+        def cli(filename):
+           click.open_file(filename, 'w', atomic=True).close()
+
+        result = runner.invoke(cli, ['existing.txt'])
+        assert result.exception is None
+        assert stat.S_IMODE(os.stat('existing.txt').st_mode) == permissions
+
+
+@pytest.mark.skipif(WIN, reason='os.stat() is not fully supported on Windows.')
+def test_open_file_atomic_permissions_new_file(runner):
+    with runner.isolated_filesystem():
+        @click.command()
+        @click.argument('filename')
+        def cli(filename):
+            click.open_file(filename, 'w', atomic=True).close()
+
+        # Create a test file to get the expected permissions for new files
+        # according to the current umask.
+        with open('test.txt', 'w'):
+            pass
+        permissions = stat.S_IMODE(os.stat('test.txt').st_mode)
+
+        result = runner.invoke(cli, ['new.txt'])
+        assert result.exception is None
+        assert stat.S_IMODE(os.stat('new.txt').st_mode) == permissions
+
+
 @pytest.mark.xfail(WIN and not PY2, reason='God knows ...')
 def test_iter_keepopenfile(tmpdir):
     expected = list(map(str, range(10)))