From b9d12438cd9343f3bff959ab0809885481fa4c87 Mon Sep 17 00:00:00 2001 From: Arrielle Opotowsky Date: Tue, 30 Dec 2025 20:49:44 +0000 Subject: [PATCH 1/9] commit the temp hack I need to get my code working --- armi/utils/pathTools.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/armi/utils/pathTools.py b/armi/utils/pathTools.py index 7ffd767df0..bc52075184 100644 --- a/armi/utils/pathTools.py +++ b/armi/utils/pathTools.py @@ -17,6 +17,7 @@ manipulations. """ +import getpass import importlib import os import pathlib @@ -213,11 +214,15 @@ def cleanPath(path, mpiRank=0): if validPath in path.lower(): valid = True + # Hack for now, so I can test + if getpass.getuser() in path: + valid = True + if pathlib.Path(context.APP_DATA) in pathlib.Path(path).parents: valid = True if not valid: - raise Exception("You tried to delete {0}, but it does not seem safe to do so.".format(path)) + raise Exception(f"You tried to delete {path}, but it does not seem safe to do so.") # delete the file/directory from only one process if mpiRank == context.MPI_RANK: From b4c71ce724b98af9619e26968550b0b37c64adad Mon Sep 17 00:00:00 2001 From: Arrielle Opotowsky Date: Tue, 30 Dec 2025 23:52:16 +0000 Subject: [PATCH 2/9] temp dir changer env variable edit --- armi/utils/directoryChangers.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/armi/utils/directoryChangers.py b/armi/utils/directoryChangers.py index d4f48dbed0..abaf4655aa 100644 --- a/armi/utils/directoryChangers.py +++ b/armi/utils/directoryChangers.py @@ -252,6 +252,11 @@ def __init__( outputPath, ) + # If an application sets this environment variable, all root args in all `TempDirChanger` uses are overriden + # with a different root path. This is useful for running unit tests in a read-only environment. + if os.environ.get("TEMP_ROOT_PATH"): + root = os.environ["TEMP_ROOT_PATH"] + # If no root dir is given, the default path comes from context.getFastPath, which # *might* be relative to the cwd, making it possible to delete unintended files. # So this check is here to ensure that if we grab a path from context, it is a From b267e41f5ce2bdff3b748ef38f0a64abaafe2691 Mon Sep 17 00:00:00 2001 From: Arrielle Opotowsky Date: Wed, 31 Dec 2025 01:33:26 +0000 Subject: [PATCH 3/9] I think I'm satisfied with this and its safety. unit tests incoming next --- armi/utils/directoryChangers.py | 2 +- armi/utils/pathTools.py | 40 ++++++++++++++++----------------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/armi/utils/directoryChangers.py b/armi/utils/directoryChangers.py index abaf4655aa..c1e8f9b49b 100644 --- a/armi/utils/directoryChangers.py +++ b/armi/utils/directoryChangers.py @@ -298,7 +298,7 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, traceback): DirectoryChanger.__exit__(self, exc_type, exc_value, traceback) try: - pathTools.cleanPath(self.destination, context.MPI_RANK) + pathTools.cleanPath(self.destination, mpiRank=context.MPI_RANK, tempDir=True) except PermissionError: if os.name == "nt": runLog.warning( diff --git a/armi/utils/pathTools.py b/armi/utils/pathTools.py index bc52075184..edb3e93ce7 100644 --- a/armi/utils/pathTools.py +++ b/armi/utils/pathTools.py @@ -17,7 +17,6 @@ manipulations. """ -import getpass import importlib import os import pathlib @@ -27,6 +26,7 @@ from armi import context, runLog from armi.utils import safeCopy +# TODO: This needs to be updated, since I don't think any of these are in common use anymore DO_NOT_CLEAN_PATHS = [ "armiruns", "failedruns", @@ -34,7 +34,6 @@ "mongoose", "shufflebranches", "snapshot", - "tests", ] @@ -187,19 +186,14 @@ def moduleAndAttributeExist(pathAttr): return moduleAttributeName in userSpecifiedModule.__dict__ -def cleanPath(path, mpiRank=0): +def cleanPath(path, mpiRank=0, tempDir=False): """Recursively delete a path. - !!! Be careful with this !!! It can delete the entire cluster. + !!! Be careful with this !!! It can delete anything a user has write privileges to. - We add copious os.path.exists checks in case an MPI set of things is trying to delete everything - at the same time. Always check filenames for some special flag when calling this, especially - with full permissions on the cluster. You could accidentally delete everyone's work with one - misplaced line! This doesn't ask questions. - - Safety nets include an allow-list of paths. - - This makes use of shutil.rmtree and os.remove + This function checks for a few cases we know to be OK to delete: (1) Any temporary directory and (2) anything under + _FAST_PATH. Before moving on with deletion, it ensures the path doesn't contain any of the ``DO_NOT_CLEAN_PATHS`` + keywords. This will undo any path that was set to ``valid=True`` for deletion. Returns ------- @@ -210,21 +204,25 @@ def cleanPath(path, mpiRank=0): if not os.path.exists(path): return True - for validPath in DO_NOT_CLEAN_PATHS: - if validPath in path.lower(): - valid = True - - # Hack for now, so I can test - if getpass.getuser() in path: + # Any tempDir can be deleted + if tempDir: valid = True - if pathlib.Path(context.APP_DATA) in pathlib.Path(path).parents: + # If the path slated for deletion is a subdirectory of _FAST_PATH, then cool, delete. + # _FAST_PATH itself gets deleted on program exit. + if path.is_relative_to(context.getFastPath()): valid = True + # Make sure the path we want to delete isn't in our do-not-delete list. Run this last in case anything was set to + # True before that is in `DO_NOT_CLEAN_PATHS` + for dndPath in DO_NOT_CLEAN_PATHS: + if dndPath in path.lower(): + valid = False + if not valid: raise Exception(f"You tried to delete {path}, but it does not seem safe to do so.") - # delete the file/directory from only one process + # Delete the file/directory from only one process if mpiRank == context.MPI_RANK: if os.path.exists(path) and os.path.isdir(path): shutil.rmtree(path) @@ -232,7 +230,7 @@ def cleanPath(path, mpiRank=0): # it's just a file. Delete it. os.remove(path) - # Potentially, wait for the deletion to finish. + # Deletions are not immediate, so wait for it to finish. maxLoops = 6 waitTime = 0.5 loopCounter = 0 From 4073fa65442a79f23e0da54bf94fd80690b0d7d9 Mon Sep 17 00:00:00 2001 From: Arrielle Opotowsky Date: Wed, 31 Dec 2025 10:30:23 -0600 Subject: [PATCH 4/9] str -> pathlib oops, might want to make sure the path is the right type! --- armi/utils/pathTools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/armi/utils/pathTools.py b/armi/utils/pathTools.py index edb3e93ce7..c7382d9699 100644 --- a/armi/utils/pathTools.py +++ b/armi/utils/pathTools.py @@ -210,7 +210,7 @@ def cleanPath(path, mpiRank=0, tempDir=False): # If the path slated for deletion is a subdirectory of _FAST_PATH, then cool, delete. # _FAST_PATH itself gets deleted on program exit. - if path.is_relative_to(context.getFastPath()): + if pathlib.Path(path).is_relative_to(pathlib.Path(context.getFastPath())): valid = True # Make sure the path we want to delete isn't in our do-not-delete list. Run this last in case anything was set to From d11bc73a65e4357c362f1659f319b122968f5876 Mon Sep 17 00:00:00 2001 From: Arrielle Opotowsky Date: Fri, 2 Jan 2026 03:07:24 +0000 Subject: [PATCH 5/9] remove DO_NOT_CLEAN_PATHS and edit the docstring --- armi/utils/pathTools.py | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/armi/utils/pathTools.py b/armi/utils/pathTools.py index edb3e93ce7..13302d2579 100644 --- a/armi/utils/pathTools.py +++ b/armi/utils/pathTools.py @@ -26,16 +26,6 @@ from armi import context, runLog from armi.utils import safeCopy -# TODO: This needs to be updated, since I don't think any of these are in common use anymore -DO_NOT_CLEAN_PATHS = [ - "armiruns", - "failedruns", - "mc2run", - "mongoose", - "shufflebranches", - "snapshot", -] - def armiAbsPath(*pathParts): """Convert a list of path components to an absolute path, without drive letters if possible.""" @@ -187,13 +177,11 @@ def moduleAndAttributeExist(pathAttr): def cleanPath(path, mpiRank=0, tempDir=False): - """Recursively delete a path. - - !!! Be careful with this !!! It can delete anything a user has write privileges to. + """Recursively delete a path. This function checks for a few cases we know to be OK to delete: (1) Any + `TemporaryDirectoryChanger` instance and (2) anything under the ARMI `_FAST_PATH`. - This function checks for a few cases we know to be OK to delete: (1) Any temporary directory and (2) anything under - _FAST_PATH. Before moving on with deletion, it ensures the path doesn't contain any of the ``DO_NOT_CLEAN_PATHS`` - keywords. This will undo any path that was set to ``valid=True`` for deletion. + Be careful with editing this! Do not make it a generic can-delete-anything function, because it could in theory + delete anything a user has write permissions on. Returns ------- @@ -213,12 +201,6 @@ def cleanPath(path, mpiRank=0, tempDir=False): if path.is_relative_to(context.getFastPath()): valid = True - # Make sure the path we want to delete isn't in our do-not-delete list. Run this last in case anything was set to - # True before that is in `DO_NOT_CLEAN_PATHS` - for dndPath in DO_NOT_CLEAN_PATHS: - if dndPath in path.lower(): - valid = False - if not valid: raise Exception(f"You tried to delete {path}, but it does not seem safe to do so.") From fb00bf9ca049d3c199ffdbbc60d4ac9b6101323e Mon Sep 17 00:00:00 2001 From: Arrielle Opotowsky Date: Fri, 2 Jan 2026 03:35:29 +0000 Subject: [PATCH 6/9] fix failing unit tests --- armi/utils/outputCache.py | 3 ++- armi/utils/tests/test_pathTools.py | 40 +++++++++++++++--------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/armi/utils/outputCache.py b/armi/utils/outputCache.py index 389a47ee41..a0258cbad1 100644 --- a/armi/utils/outputCache.py +++ b/armi/utils/outputCache.py @@ -176,7 +176,8 @@ def deleteCache(cachedFolder): if "cache" not in str(cachedFolder).lower(): raise RuntimeError("Cache location must contain keyword: `cache`.") - cleanPath(cachedFolder) + # We can consider caches temporary directories, since they are not important containers for simulation results. + cleanPath(cachedFolder, tempDir=True) def cacheCall(cacheDir, executablePath, inputPaths, outputFileNames, execute=None, tearDown=None): diff --git a/armi/utils/tests/test_pathTools.py b/armi/utils/tests/test_pathTools.py index ed258487f3..f15f5eeb7b 100644 --- a/armi/utils/tests/test_pathTools.py +++ b/armi/utils/tests/test_pathTools.py @@ -104,46 +104,46 @@ def test_moduleAndAttributeExist(self): def test_cleanPathNoMpi(self): """Simple tests of cleanPath(), in the no-MPI scenario.""" with TemporaryDirectoryChanger(): - # TEST 0: File is not safe to delete, due to name pathing + # TEST 0: File is not safe to delete, due not being a temp dir or under FAST_PATH filePath0 = "test0_cleanPathNoMpi" open(filePath0, "w").write("something") - self.assertTrue(os.path.exists(filePath0)) with self.assertRaises(Exception): pathTools.cleanPath(filePath0, mpiRank=0) - # TEST 1: Delete a single file - filePath1 = "test1_cleanPathNoMpi_mongoose" + # TEST 1: Delete a single file under FAST_PATH + filePath1 = os.path.join(context.getFastPath(), "test1_cleanPathNoMpi") open(filePath1, "w").write("something") - self.assertTrue(os.path.exists(filePath1)) pathTools.cleanPath(filePath1, mpiRank=0) self.assertFalse(os.path.exists(filePath1)) - # TEST 2: Delete an empty directory - dir2 = "mongoose" + # TEST 2: Delete an empty directory under FAST_PATH + dir2 = os.path.join(context.getFastPath(), "letitgo") os.mkdir(dir2) - self.assertTrue(os.path.exists(dir2)) pathTools.cleanPath(dir2, mpiRank=0) self.assertFalse(os.path.exists(dir2)) - # TEST 3: Delete a directory with two files inside - # create directory - dir3 = "mongoose" + # TEST 3: Delete an empty directory with tempDir=True + dir3 = "noyoureadirectory" os.mkdir(dir3) - - # throw in a couple of simple text files - open(os.path.join(dir3, "file1.txt"), "w").write("something1") - open(os.path.join(dir3, "file2.txt"), "w").write("something2") - - # delete the directory and test self.assertTrue(os.path.exists(dir3)) - self.assertTrue(os.path.exists(os.path.join(dir3, "file1.txt"))) - self.assertTrue(os.path.exists(os.path.join(dir3, "file2.txt"))) - pathTools.cleanPath(dir3, mpiRank=0) + pathTools.cleanPath(dir3, mpiRank=0, tempDir=True) self.assertFalse(os.path.exists(dir3)) + # TEST 4: Delete a directory with two files inside with tempDir=True + dir4 = "dirplease" + os.mkdir(dir4) + open(os.path.join(dir4, "file1.txt"), "w").write("something1") + open(os.path.join(dir4, "file2.txt"), "w").write("something2") + # delete the directory and test + self.assertTrue(os.path.exists(dir4)) + self.assertTrue(os.path.exists(os.path.join(dir4, "file1.txt"))) + self.assertTrue(os.path.exists(os.path.join(dir4, "file2.txt"))) + pathTools.cleanPath(dir4, mpiRank=0, tempDir=True) + self.assertFalse(os.path.exists(dir4)) + def test_isFilePathNewer(self): with TemporaryDirectoryChanger(): path1 = "test_isFilePathNewer1.txt" From 737dba41913987a9a42a195cd1536a1371331698 Mon Sep 17 00:00:00 2001 From: Arrielle Opotowsky Date: Fri, 2 Jan 2026 04:06:00 +0000 Subject: [PATCH 7/9] try this. ruamel.yaml released 0.19.0 yesterday and it messed up all CI for >=py311 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c1ec042115..9257098392 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "pluggy>=1.2.0", # Central tool behind the ARMI Plugin system "pyDOE>=0.3.8", # We import a Latin-hypercube algorithm to explore a phase space "pyevtk>=1.2.0", # Handles binary VTK visualization files - "ruamel.yaml ; python_version >= '3.11.0'", # Our foundational YAML library + "ruamel.yaml<0.19.0; python_version >= '3.11.0'", # Our foundational YAML library "ruamel.yaml.clib ; python_version >= '3.11.0'", # C-based core of ruamel below "ruamel.yaml.clib<=0.2.7 ; python_version < '3.11.0'", # C-based core of ruamel below "ruamel.yaml<=0.17.21 ; python_version < '3.11.0'", # Our foundational YAML library From 35d03e8a63d59a462a58c149c9325a531c973649 Mon Sep 17 00:00:00 2001 From: Arrielle Opotowsky Date: Fri, 2 Jan 2026 04:25:34 +0000 Subject: [PATCH 8/9] these should pass now --- armi/tests/test_mpiFeatures.py | 40 +++++++++++++++++----------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/armi/tests/test_mpiFeatures.py b/armi/tests/test_mpiFeatures.py index 46fbdc7d2a..1ce96eec25 100644 --- a/armi/tests/test_mpiFeatures.py +++ b/armi/tests/test_mpiFeatures.py @@ -297,50 +297,50 @@ class MpiPathToolsTests(unittest.TestCase): def test_cleanPathMpi(self): """Simple tests of cleanPath(), in the MPI scenario.""" with TemporaryDirectoryChanger(): - # TEST 0: File is not safe to delete, due to name pathing + # TEST 0: File is not safe to delete, due to due not being a temp dir or under FAST_PATH filePath0 = "test0_cleanPathNoMpi" open(filePath0, "w").write("something") - self.assertTrue(os.path.exists(filePath0)) with self.assertRaises(Exception): pathTools.cleanPath(filePath0, mpiRank=context.MPI_RANK) MPI_COMM.barrier() - # TEST 1: Delete a single file - filePath1 = "test1_cleanPathNoMpi_mongoose" + # TEST 1: Delete a single file under FAST_PATH + filePath1 = os.path.join(context.getFastPath(), "test1_cleanPathNoMpi") open(filePath1, "w").write("something") - self.assertTrue(os.path.exists(filePath1)) pathTools.cleanPath(filePath1, mpiRank=context.MPI_RANK) MPI_COMM.barrier() self.assertFalse(os.path.exists(filePath1)) - # TEST 2: Delete an empty directory - dir2 = "mongoose" + # TEST 2: Delete an empty directory under FAST_PATH + dir2 = os.path.join(context.getFastPath(), "gimmeonereason") os.mkdir(dir2) - self.assertTrue(os.path.exists(dir2)) pathTools.cleanPath(dir2, mpiRank=context.MPI_RANK) MPI_COMM.barrier() self.assertFalse(os.path.exists(dir2)) - # TEST 3: Delete a directory with two files inside - # create directory - dir3 = "mongoose" + # TEST 3: Delete an empty directory with tempDir=True + dir3 = "tostayhere" os.mkdir(dir3) - - # throw in a couple of simple text files - open(os.path.join(dir3, "file1.txt"), "w").write("something1") - open(os.path.join(dir3, "file2.txt"), "w").write("something2") - - # delete the directory and test self.assertTrue(os.path.exists(dir3)) - self.assertTrue(os.path.exists(os.path.join(dir3, "file1.txt"))) - self.assertTrue(os.path.exists(os.path.join(dir3, "file2.txt"))) - pathTools.cleanPath(dir3, mpiRank=context.MPI_RANK) + pathTools.cleanPath(dir3, mpiRank=context.MPI_RANK, tempDir=True) MPI_COMM.barrier() self.assertFalse(os.path.exists(dir3)) + # TEST 3: Delete a directory with two files inside with tempDir=True + dir4 = "andilldirrightbackaround" + os.mkdir(dir4) + open(os.path.join(dir4, "file1.txt"), "w").write("something1") + open(os.path.join(dir4, "file2.txt"), "w").write("something2") + self.assertTrue(os.path.exists(dir4)) + self.assertTrue(os.path.exists(os.path.join(dir4, "file1.txt"))) + self.assertTrue(os.path.exists(os.path.join(dir4, "file2.txt"))) + pathTools.cleanPath(dir4, mpiRank=context.MPI_RANK, tempDir=True) + MPI_COMM.barrier() + self.assertFalse(os.path.exists(dir4)) + class TestContextMpi(unittest.TestCase): """Parallel tests for the Context module.""" From 0e08aed8dacbf7d3028975331f5bb8c7d5247b7f Mon Sep 17 00:00:00 2001 From: Arrielle Opotowsky Date: Wed, 7 Jan 2026 05:07:55 +0000 Subject: [PATCH 9/9] edit where logs goes too based on the env variable (this changes scope of PR and need to update that) --- armi/runLog.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/armi/runLog.py b/armi/runLog.py index 5fd6b17990..c867859ae6 100644 --- a/armi/runLog.py +++ b/armi/runLog.py @@ -60,6 +60,8 @@ self._log({1}, message, args, **kws) logging.Logger.{0} = {0}""" LOG_DIR = os.path.join(os.getcwd(), "logs") +if os.environ.get("TEMP_ROOT_PATH"): + LOG_DIR = os.path.join(os.environ["TEMP_ROOT_PATH"], "logs") OS_SECONDS_TIMEOUT = 2 * 60 SEP = "|" STDERR_LOGGER_NAME = "ARMI_ERROR"