Skip to content

Commit

Permalink
Add a tag to the cachedir
Browse files Browse the repository at this point in the history
Since there are now two files to make when a cachedir is created,
use the temporary-dir -> rename technique.

CacheDir tests no longer pre-create the cache directory, they should
be verifying it's created properly upon request (one unit test still
makes sure passing an existing empty directory works, too).

Signed-off-by: Mats Wichmann <[email protected]>
  • Loading branch information
mwichmann committed Oct 18, 2024
1 parent d567831 commit 1e861f3
Show file tree
Hide file tree
Showing 19 changed files with 177 additions and 114 deletions.
134 changes: 92 additions & 42 deletions SCons/CacheDir.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,20 @@
import os
import stat
import sys
import tempfile
import uuid

import SCons.Action
import SCons.Errors
import SCons.Warnings
import SCons.Util

CACHE_PREFIX_LEN = 2 # first two characters used as subdirectory name
CACHE_TAG = (
b"Signature: 8a477f597d28d172789f06886806bc55\n"
b"# SCons cache directory - see https://bford.info/cachedir/\n"
)

cache_enabled = True
cache_debug = False
cache_force = False
Expand Down Expand Up @@ -67,20 +74,20 @@ def CacheRetrieveFunc(target, source, env) -> int:
fs.chmod(t.get_internal_path(), stat.S_IMODE(st[stat.ST_MODE]) | stat.S_IWRITE)
return 0

def CacheRetrieveString(target, source, env) -> None:
def CacheRetrieveString(target, source, env) -> str:
t = target[0]
fs = t.fs
cd = env.get_CacheDir()
cachedir, cachefile = cd.cachepath(t)
if t.fs.exists(cachefile):
return "Retrieved `%s' from cache" % t.get_internal_path()
return None
return ""

CacheRetrieve = SCons.Action.Action(CacheRetrieveFunc, CacheRetrieveString)

CacheRetrieveSilent = SCons.Action.Action(CacheRetrieveFunc, None)

def CachePushFunc(target, source, env):
def CachePushFunc(target, source, env) -> None:
if cache_readonly:
return

Expand Down Expand Up @@ -134,8 +141,7 @@ def CachePushFunc(target, source, env):
class CacheDir:

def __init__(self, path) -> None:
"""
Initialize a CacheDir object.
"""Initialize a CacheDir object.
The cache configuration is stored in the object. It
is read from the config file in the supplied path if
Expand All @@ -147,53 +153,97 @@ def __init__(self, path) -> None:
self.path = path
self.current_cache_debug = None
self.debugFP = None
self.config = dict()
if path is None:
return

self._readconfig(path)
self.config = {}
if path is not None:
self._readconfig(path)

def _add_config(self, path: str) -> None:
"""Create the cache config file in *path*.
Locking isn't necessary in the normal case - when the cachedir is
being created - because it's written to a unique directory first,
before the directory is renamed. But it is legal to call CacheDir
with an existing directory, which may be missing the config file,
and in that case we do need locking. Simpler to always lock.
"""
config_file = os.path.join(path, 'config')
# TODO: this breaks the "unserializable config object" test which
# does some crazy stuff, so for now don't use setdefault. It does
# seem like it would be better to preserve an exisiting value.
# self.config.setdefault('prefix_len', CACHE_PREFIX_LEN)
self.config['prefix_len'] = CACHE_PREFIX_LEN
with SCons.Util.FileLock(config_file, timeout=5, writer=True), open(
config_file, "x"
) as config:
try:
json.dump(self.config, config)
except Exception:
msg = "Failed to write cache configuration for " + path
raise SCons.Errors.SConsEnvironmentError(msg)

# Add the tag file "carelessly" - the contents are not used by SCons
# so we don't care about the chance of concurrent writes.
try:
tagfile = os.path.join(path, "CACHEDIR.TAG")
with open(tagfile, 'xb') as cachedir_tag:
cachedir_tag.write(CACHE_TAG)
except FileExistsError:
pass

def _readconfig(self, path):
"""
Read the cache config.
def _mkdir_atomic(self, path: str) -> bool:
"""Create cache directory at *path*.
If directory or config file do not exist, create. Take advantage
of Py3 capability in os.makedirs() and in file open(): just try
the operation and handle failure appropriately.
Uses directory renaming to avoid races. If we are actually
creating the dir, populate it with the metadata files at the
same time as that's the safest way. But it's not illegal to point
CacheDir at an existing directory that wasn't a cache previously,
so we may have to do that elsewhere, too.
Omit the check for old cache format, assume that's old enough
there will be none of those left to worry about.
Returns:
``True`` if it we created the dir, ``False`` if already existed,
:param path: path to the cache directory
Raises:
SConsEnvironmentError: if we tried and failed to create the cache.
"""
config_file = os.path.join(path, 'config')
directory = os.path.abspath(path)
if os.path.exists(directory):
return False

try:
# still use a try block even with exist_ok, might have other fails
os.makedirs(path, exist_ok=True)
except OSError:
tempdir = tempfile.TemporaryDirectory(dir=os.path.dirname(directory))
except OSError as e:
msg = "Failed to create cache directory " + path
raise SCons.Errors.SConsEnvironmentError(msg)
raise SCons.Errors.SConsEnvironmentError(msg) from e
self._add_config(tempdir.name)
with tempdir:
try:
os.rename(tempdir.name, directory)
return True
except Exception as e:
# did someone else get there first?
if os.path.isdir(directory):
return False
msg = "Failed to create cache directory " + path
raise SCons.Errors.SConsEnvironmentError(msg) from e

def _readconfig(self, path: str) -> None:
"""Read the cache config from *path*.
If directory or config file do not exist, create and populate.
"""
config_file = os.path.join(path, 'config')
created = self._mkdir_atomic(path)
if not created and not os.path.isfile(config_file):
# Could have been passed an empty directory
self._add_config(path)
try:
with SCons.Util.FileLock(config_file, timeout=5, writer=True), open(
config_file, "x"
with SCons.Util.FileLock(config_file, timeout=5, writer=False), open(
config_file
) as config:
self.config['prefix_len'] = 2
try:
json.dump(self.config, config)
except Exception:
msg = "Failed to write cache configuration for " + path
raise SCons.Errors.SConsEnvironmentError(msg)
except FileExistsError:
try:
with SCons.Util.FileLock(config_file, timeout=5, writer=False), open(
config_file
) as config:
self.config = json.load(config)
except (ValueError, json.decoder.JSONDecodeError):
msg = "Failed to read cache configuration for " + path
raise SCons.Errors.SConsEnvironmentError(msg)
self.config = json.load(config)
except (ValueError, json.decoder.JSONDecodeError):
msg = "Failed to read cache configuration for " + path
raise SCons.Errors.SConsEnvironmentError(msg)

def CacheDebug(self, fmt, target, cachefile) -> None:
if cache_debug != self.current_cache_debug:
Expand Down Expand Up @@ -252,7 +302,7 @@ def is_enabled(self) -> bool:
def is_readonly(self) -> bool:
return cache_readonly

def get_cachedir_csig(self, node):
def get_cachedir_csig(self, node) -> str:
cachedir, cachefile = self.cachepath(node)
if cachefile and os.path.exists(cachefile):
return SCons.Util.hash_file_signature(cachefile, SCons.Node.FS.File.hash_chunksize)
Expand Down
101 changes: 56 additions & 45 deletions SCons/CacheDirTests.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@
import tempfile
import stat

from TestCmd import TestCmd
from TestCmd import TestCmd, IS_WINDOWS

import SCons.CacheDir
import SCons.Node.FS

built_it = None

Expand Down Expand Up @@ -62,15 +63,11 @@ def get_CacheDir(self):
return self.cachedir

class BaseTestCase(unittest.TestCase):
"""
Base fixtures common to our other unittest classes.
"""
"""Base fixtures common to our other unittest classes."""

def setUp(self) -> None:
self.test = TestCmd(workdir='')

import SCons.Node.FS
self.fs = SCons.Node.FS.FS()

self._CacheDir = SCons.CacheDir.CacheDir('cache')

def File(self, name, bsig=None, action=Action()):
Expand All @@ -83,21 +80,19 @@ def File(self, name, bsig=None, action=Action()):
return node

def tearDown(self) -> None:
os.remove(os.path.join(self._CacheDir.path, 'config'))
os.rmdir(self._CacheDir.path)
# Should that be shutil.rmtree?
shutil.rmtree(self._CacheDir.path)

class CacheDirTestCase(BaseTestCase):
"""
Test calling CacheDir code directly.
"""
"""Test calling CacheDir code directly."""

def test_cachepath(self) -> None:
"""Test the cachepath() method"""

# Verify how the cachepath() method determines the name
# of the file in cache.
def my_collect(list, hash_format=None):
return list[0]

save_collect = SCons.Util.hash_collect
SCons.Util.hash_collect = my_collect

Expand All @@ -112,6 +107,21 @@ def my_collect(list, hash_format=None):
finally:
SCons.Util.hash_collect = save_collect

class CacheDirExistsTestCase(unittest.TestCase):
"""Test passing an existing but not setup cache directory."""

def setUp(self) -> None:
self.test = TestCmd(workdir='')
self.test.subdir('ex-cache') # force an empty dir
cache = self.test.workpath('ex-cache')
self.fs = SCons.Node.FS.FS()
self._CacheDir = SCons.CacheDir.CacheDir(cache)

def test_existing_cachedir(self) -> None:
"""Test the setup happened even though cache already existed."""
assert os.path.exists(self.test.workpath('ex-cache', 'config'))
assert os.path.exists(self.test.workpath('ex-cache', 'CACHEDIR.TAG'))

class ExceptionTestCase(unittest.TestCase):
"""Test that the correct exceptions are thrown by CacheDir."""

Expand All @@ -124,28 +134,33 @@ def setUp(self) -> None:
def tearDown(self) -> None:
shutil.rmtree(self.tmpdir)

@unittest.skipIf(sys.platform.startswith("win"), "This fixture will not trigger an OSError on Windows")
@unittest.skipIf(
IS_WINDOWS,
"This fixture will not trigger an OSError on Windows",
)
def test_throws_correct_on_OSError(self) -> None:
"""Test that the correct error is thrown when cache directory cannot be created."""
"""Test for correct error when cache directory cannot be created."""
privileged_dir = os.path.join(self.tmpdir, "privileged")
try:
os.mkdir(privileged_dir)
os.chmod(privileged_dir, stat.S_IREAD)
cd = SCons.CacheDir.CacheDir(os.path.join(privileged_dir, "cache"))
assert False, "Should have raised exception and did not"
except SCons.Errors.SConsEnvironmentError as e:
assert str(e) == "Failed to create cache directory {}".format(os.path.join(privileged_dir, "cache"))
finally:
os.chmod(privileged_dir, stat.S_IWRITE | stat.S_IEXEC | stat.S_IREAD)
shutil.rmtree(privileged_dir)

cachedir_path = os.path.join(privileged_dir, "cache")
os.makedirs(privileged_dir, exist_ok=True)
os.chmod(privileged_dir, stat.S_IREAD)
with self.assertRaises(SCons.Errors.SConsEnvironmentError) as cm:
cd = SCons.CacheDir.CacheDir(cachedir_path)
self.assertEqual(
str(cm.exception),
"Failed to create cache directory " + cachedir_path
)
os.chmod(privileged_dir, stat.S_IWRITE | stat.S_IEXEC | stat.S_IREAD)
shutil.rmtree(privileged_dir)

def test_throws_correct_when_failed_to_write_configfile(self) -> None:
"""Test for correct error if cache config file cannot be created."""

class Unserializable:
"""A class which the JSON should not be able to serialize"""
"""A class which the JSON module should not be able to serialize."""

def __init__(self, oldconfig) -> None:
self.something = 1 # Make the object unserializable
self.something = 1 # Make the object unserializable
# Pretend to be the old config just enough
self.__dict__["prefix_len"] = oldconfig["prefix_len"]

Expand All @@ -160,16 +175,17 @@ def __setitem__(self, name, value) -> None:

oldconfig = self._CacheDir.config
self._CacheDir.config = Unserializable(oldconfig)

# Remove the config file that got created on object creation
# so that _readconfig* will try to rewrite it
old_config = os.path.join(self._CacheDir.path, "config")
os.remove(old_config)

try:
with self.assertRaises(SCons.Errors.SConsEnvironmentError) as cm:
self._CacheDir._readconfig(self._CacheDir.path)
assert False, "Should have raised exception and did not"
except SCons.Errors.SConsEnvironmentError as e:
assert str(e) == "Failed to write cache configuration for {}".format(self._CacheDir.path)
self.assertEqual(
str(cm.exception),
"Failed to write cache configuration for " + self._CacheDir.path,
)

def test_raise_environment_error_on_invalid_json(self) -> None:
config_file = os.path.join(self._CacheDir.path, "config")
Expand All @@ -180,17 +196,16 @@ def test_raise_environment_error_on_invalid_json(self) -> None:
with open(config_file, "w") as cfg:
cfg.write(content)

try:
# Construct a new cache dir that will try to read the invalid config
with self.assertRaises(SCons.Errors.SConsEnvironmentError) as cm:
# Construct a new cachedir that will try to read the invalid config
new_cache_dir = SCons.CacheDir.CacheDir(self._CacheDir.path)
assert False, "Should have raised exception and did not"
except SCons.Errors.SConsEnvironmentError as e:
assert str(e) == "Failed to read cache configuration for {}".format(self._CacheDir.path)
self.assertEqual(
str(cm.exception),
"Failed to read cache configuration for " + self._CacheDir.path,
)

class FileTestCase(BaseTestCase):
"""
Test calling CacheDir code through Node.FS.File interfaces.
"""
"""Test calling CacheDir code through Node.FS.File interfaces."""
# These tests were originally in Nodes/FSTests.py and got moved
# when the CacheDir support was refactored into its own module.
# Look in the history for Node/FSTests.py if any of this needs
Expand Down Expand Up @@ -266,9 +281,7 @@ def test_CacheRetrieveSilent(self) -> None:

def test_CachePush(self) -> None:
"""Test the CachePush() function"""

save_CachePush = SCons.CacheDir.CachePush

SCons.CacheDir.CachePush = self.push

try:
Expand Down Expand Up @@ -301,7 +314,6 @@ def test_CachePush(self) -> None:

def test_warning(self) -> None:
"""Test raising a warning if we can't copy a file to cache."""

test = TestCmd(workdir='')

save_copy2 = shutil.copy2
Expand Down Expand Up @@ -329,7 +341,6 @@ def mkdir(dir, mode: int=0) -> None:

def test_no_strfunction(self) -> None:
"""Test handling no strfunction() for an action."""

save_CacheRetrieveSilent = SCons.CacheDir.CacheRetrieveSilent

f8 = self.File("cd.f8", 'f8_bsig')
Expand Down
Loading

0 comments on commit 1e861f3

Please sign in to comment.