Repository: desihub/desiutil

Issue:
desiInstall can't alter .version files with new permissions settings.
So, I tagged 1.9.14, and went to install it with `desiInstall -d desiutil 1.9.14`.  In the process of restricting write access, even to the desi user, I also made .version files read-only.  However, `desiInstall` expects to be able to write to such files.  For now, I am going to restore user-read to all .version files in 20180709-1.2.6-spec.

Comments:

Patch:
diff --git a/doc/changes.rst b/doc/changes.rst
index f154327..6f23c11 100644
--- a/doc/changes.rst
+++ b/doc/changes.rst
@@ -10,7 +10,11 @@ Change Log
 1.9.15 (unreleased)
 -------------------
 
-* Draw ecliptic in all-sky plots.
+* Set read-only permissions on all Module files, and unlock them as needed (PR `#125`_).
+* Draw ecliptic in all-sky plots (PR `#124`_).
+
+.. _`#125`: https://github.com/desihub/desiutil/pull/125
+.. _`#124`: https://github.com/desihub/desiutil/pull/124
 
 1.9.14 (2018-10-05)
 -------------------
diff --git a/py/desiutil/install.py b/py/desiutil/install.py
index 11f22e1..1f7077f 100644
--- a/py/desiutil/install.py
+++ b/py/desiutil/install.py
@@ -720,7 +720,6 @@ class DesiInstall(object):
                 outfile = os.path.join(module_directory,
                                        self.module_keywords['name'],
                                        self.module_keywords['version'])
-                os.chmod(outfile, 0o440)
             except OSError as ose:
                 self.log.critical(ose.strerror)
                 raise DesiInstallException(ose.strerror)
diff --git a/py/desiutil/io.py b/py/desiutil/io.py
index 4a9d2a5..b515e43 100644
--- a/py/desiutil/io.py
+++ b/py/desiutil/io.py
@@ -9,6 +9,8 @@ Module for I/O related code.
 """
 from __future__ import (print_function, absolute_import, division,
                         unicode_literals)
+from contextlib import contextmanager
+
 
 try:
     basestring
@@ -218,3 +220,55 @@ def decode_table(data, encoding='ascii', native=True):
 
     table.meta['ENCODING'] = encoding
     return table
+
+
+@contextmanager
+def unlock_file(*args, **kwargs):
+    """Unlock a read-only file, return a file-like object, and restore the
+    read-only state when done.  Arguments are the same as :func:`open`.
+
+    Returns
+    -------
+    file-like
+        A file-like object, as returned by :func:`open`.
+
+    Notes
+    -----
+    * This assumes that the user of this function is also the owner of the
+      file. :func:`os.chmod` would not be expected to work in any other
+      circumstance.
+    * Technically, this restores the *original* permissions of the file, it
+      does not care what the original permissions were.
+    * If the named file does not exist, this function effectively does not
+      attempt to guess what the final permissions of the file would be.  In
+      other words, it just does whatever :func:`open` would do.  In this case
+      it is the user's responsibilty to change permissions as needed after
+      creating the file.
+
+    Examples
+    --------
+    >>> with unlock_file('read-only.txt', 'w') as f:
+    ...     f.write(new_data)
+    """
+    import os
+    import stat
+    w = stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH
+    #
+    # Get original permissions, unlock permissions
+    #
+    # uid = os.getuid()
+    old_mode = None
+    if os.path.exists(args[0]):
+        old_mode = stat.S_IMODE(os.stat(args[0]).st_mode)
+        os.chmod(args[0], old_mode | stat.S_IWUSR)
+    f = open(*args, **kwargs)
+    try:
+        yield f
+    finally:
+        #
+        # Restore permissions to read-only state.
+        #
+        f.close()
+        if old_mode is None:
+            old_mode = stat.S_IMODE(os.stat(args[0]).st_mode)
+        os.chmod(args[0], old_mode & ~w)
diff --git a/py/desiutil/modules.py b/py/desiutil/modules.py
index f8be62f..97aebb4 100644
--- a/py/desiutil/modules.py
+++ b/py/desiutil/modules.py
@@ -253,8 +253,7 @@ def process_module(module_file, module_keywords, module_dir):
                                module_keywords['version'])
     with open(module_file) as m:
         mod = m.read().format(**module_keywords)
-    with open(install_module_file, 'w') as m:
-        m.write(mod)
+    _write_module_data(install_module_file, mod)
     return mod
 
 
@@ -278,6 +277,18 @@ def default_module(module_keywords, module_dir):
     install_version_file = join(module_dir, module_keywords['name'],
                                 '.version')
     dot_version = dot_template.format(**module_keywords)
-    with open(install_version_file, 'w') as v:
-        v.write(dot_version)
+    _write_module_data(install_version_file, dot_version)
     return dot_version
+
+
+def _write_module_data(filename, data):
+    """Write and permission-lock Module file data.  This is intended
+    to consolidate some duplicated code.
+    """
+    from os import chmod
+    from stat import S_IRUSR, S_IRGRP
+    from .io import unlock_file
+    with unlock_file(filename, 'w') as f:
+        f.write(data)
+    chmod(filename, S_IRUSR | S_IRGRP)
+    return


Test patch:
diff --git a/py/desiutil/test/test_io.py b/py/desiutil/test/test_io.py
index a8b17dd..4c75f51 100644
--- a/py/desiutil/test/test_io.py
+++ b/py/desiutil/test/test_io.py
@@ -6,16 +6,24 @@ from __future__ import (absolute_import, division,
                         print_function, unicode_literals)
 # The line above will help with 2to3 support.
 import unittest
+import os
+import stat
 import sys
 import numpy as np
 from astropy.table import Table
-from ..io import combine_dicts, decode_table, encode_table, yamlify
+from ..io import combine_dicts, decode_table, encode_table, yamlify, unlock_file
 
 try:
     basestring
 except NameError:  # For Python 3
     basestring = str
 
+skipTemp = False
+try:
+    from tempfile import TemporaryDirectory
+except ImportError:
+    skipTemp = True
+
 
 class TestIO(unittest.TestCase):
     """Test desiutil.io
@@ -178,6 +186,33 @@ class TestIO(unittest.TestCase):
         self.assertEqual(dict1, {'a': {'b': {'x': 1, 'y': 2}}})
         self.assertEqual(dict2, {'a': {'b': {'p': 3, 'q': 4}}})
 
+    @unittest.skipIf(skipTemp, "Skipping test that requires tempfile.TemporaryDirectory.")
+    def test_unlock_file(self):
+        """Test the permission unlock file manager.
+        """
+        fff = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH
+        www = stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH
+        with TemporaryDirectory() as dirname:
+            filename = os.path.join(dirname, 'tempfile')
+            with open(filename, 'wb') as f:
+                f.write(b'Content\n')
+            s0 = os.stat(filename)
+            ro = stat.S_IFMT(s0.st_mode) | fff
+            os.chmod(filename, ro)
+            s1 = os.stat(filename)
+            self.assertEqual(stat.S_IMODE(s1.st_mode), fff)
+            with unlock_file(filename, 'ab') as f:
+                f.write(b'More content\n')
+                s2 = os.stat(filename)
+                self.assertEqual(stat.S_IMODE(s2.st_mode), fff | stat.S_IWUSR)
+            s3 = os.stat(filename)
+            self.assertEqual(stat.S_IMODE(s3.st_mode), fff)
+            filename = os.path.join(dirname, 'newfile')
+            with unlock_file(filename, 'wb') as f:
+                f.write(b'Some content\n')
+            s0 = os.stat(filename)
+            self.assertEqual(stat.S_IMODE(s0.st_mode) & www, 0)
+
 
 def test_suite():
     """Allows testing of only this module with the command::
diff --git a/py/desiutil/test/test_modules.py b/py/desiutil/test/test_modules.py
index a05a62c..72c073a 100644
--- a/py/desiutil/test/test_modules.py
+++ b/py/desiutil/test/test_modules.py
@@ -12,6 +12,8 @@ from os import chmod, environ, mkdir, pathsep, remove, rmdir
 from os.path import dirname, exists, isdir, join
 from sys import version_info
 from shutil import rmtree
+from tempfile import mkdtemp
+from pkg_resources import resource_filename
 from ..modules import (init_modules, configure_module, process_module,
                        default_module)
 
@@ -23,7 +25,7 @@ class TestModules(unittest.TestCase):
     @classmethod
     def setUpClass(cls):
         # Data directory
-        cls.data_dir = join(dirname(__file__), 't')
+        cls.data_dir = mkdtemp()
         cls.bin_dir = join(cls.data_dir, 'libexec')
         cls.orig_env_cache = dict()
         cls.env_cache = dict()
@@ -56,7 +58,7 @@ class TestModules(unittest.TestCase):
                 del environ[e]
             else:
                 environ[e] = cls.orig_env_cache[e]
-        rmtree(cls.bin_dir)
+        rmtree(cls.data_dir)
 
     def cache_env(self, envs):
         """Store existing environment variables in a cache and delete them.
@@ -227,7 +229,7 @@ class TestModules(unittest.TestCase):
     def test_process_module(self):
         """Test processing of module file templates.
         """
-        module_file = join(self.data_dir, 'test.module')
+        module_file = resource_filename('desiutil.test', 't/test.module')
         module_keywords = {'name': 'foo', 'version': 'bar'}
         process_module(module_file, module_keywords, self.data_dir)
         self.assertTrue(isdir(join(self.data_dir, 'foo')))