Repository: materialsvirtuallab/monty

Issue:
[Feature Request]: `compress_file` and `decompress_file` allow specify target path
### Problem

Would it be a good idea to allow user to optionally specific the output file path for `compress_file` and `decompress_file`?  I could help implement this if so.

Or is there any reason not to do so? 

Thanks

### Proposed Solution

Append a prefix to `filepath` (if not None) should suffice:
https://github.com/materialsvirtuallab/monty/blob/4d60fd4745f840354e7b3f48346eaf3cd68f2b35/monty/shutil.py#L79-L87

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct

Comments:

Patch:
diff --git a/monty/io.py b/monty/io.py
index df7f1ff..98f3857 100644
--- a/monty/io.py
+++ b/monty/io.py
@@ -17,7 +17,7 @@ from typing import IO, Generator, Union
 
 
 def zopen(filename: Union[str, Path], *args, **kwargs) -> IO:
-    r"""
+    """
     This function wraps around the bz2, gzip, lzma, xz and standard python's open
     function to deal intelligently with bzipped, gzipped or standard text
     files.
diff --git a/monty/shutil.py b/monty/shutil.py
index 1e19db3..2373984 100644
--- a/monty/shutil.py
+++ b/monty/shutil.py
@@ -7,7 +7,7 @@ import shutil
 import warnings
 from gzip import GzipFile
 from pathlib import Path
-from typing import Literal
+from typing import Literal, Optional
 
 from .io import zopen
 
@@ -65,7 +65,9 @@ def gzip_dir(path: str | Path, compresslevel: int = 6) -> None:
 
 
 def compress_file(
-    filepath: str | Path, compression: Literal["gz", "bz2"] = "gz"
+    filepath: str | Path,
+    compression: Literal["gz", "bz2"] = "gz",
+    target_dir: Optional[str | Path] = None,
 ) -> None:
     """
     Compresses a file with the correct extension. Functions like standard
@@ -76,15 +78,26 @@ def compress_file(
         filepath (str | Path): Path to file.
         compression (str): A compression mode. Valid options are "gz" or
             "bz2". Defaults to "gz".
+        target_dir (str | Path): An optional target dir where the result compressed
+            file would be stored. Defaults to None for in-place compression.
     """
     filepath = Path(filepath)
-    if compression not in ["gz", "bz2"]:
+    target_dir = Path(target_dir) if target_dir is not None else None
+
+    if compression not in {"gz", "bz2"}:
         raise ValueError("Supported compression formats are 'gz' and 'bz2'.")
+
     if filepath.suffix.lower() != f".{compression}" and not filepath.is_symlink():
-        with open(filepath, "rb") as f_in, zopen(
-            f"{filepath}.{compression}", "wb"
-        ) as f_out:
+        if target_dir is not None:
+            os.makedirs(target_dir, exist_ok=True)
+            compressed_file: str | Path = target_dir / f"{filepath.name}.{compression}"
+
+        else:
+            compressed_file = f"{str(filepath)}.{compression}"
+
+        with open(filepath, "rb") as f_in, zopen(compressed_file, "wb") as f_out:
             f_out.writelines(f_in)
+
         os.remove(filepath)
 
 
@@ -107,23 +120,37 @@ def compress_dir(path: str | Path, compression: Literal["gz", "bz2"] = "gz") ->
     return None
 
 
-def decompress_file(filepath: str | Path) -> str | None:
+def decompress_file(
+    filepath: str | Path, target_dir: Optional[str | Path] = None
+) -> str | None:
     """
     Decompresses a file with the correct extension. Automatically detects
     gz, bz2 or z extension.
 
     Args:
-        filepath (str): Path to file.
+        filepath (str | Path): Path to file.
+        target_dir (str | Path): An optional target dir where the result decompressed
+            file would be stored. Defaults to None for in-place decompression.
 
     Returns:
-        str: The decompressed file path.
+        str | None: The decompressed file path, None if no operation.
     """
     filepath = Path(filepath)
+    target_dir = Path(target_dir) if target_dir is not None else None
     file_ext = filepath.suffix
-    if file_ext.lower() in [".bz2", ".gz", ".z"] and filepath.is_file():
-        decompressed_file = Path(str(filepath).removesuffix(file_ext))
+
+    if file_ext.lower() in {".bz2", ".gz", ".z"} and filepath.is_file():
+        if target_dir is not None:
+            os.makedirs(target_dir, exist_ok=True)
+            decompressed_file: str | Path = target_dir / filepath.name.removesuffix(
+                file_ext
+            )
+        else:
+            decompressed_file = str(filepath).removesuffix(file_ext)
+
         with zopen(filepath, "rb") as f_in, open(decompressed_file, "wb") as f_out:
             f_out.writelines(f_in)
+
         os.remove(filepath)
 
         return str(decompressed_file)


Test patch:
diff --git a/tests/test_shutil.py b/tests/test_shutil.py
index 70f76d0..d5e9cab 100644
--- a/tests/test_shutil.py
+++ b/tests/test_shutil.py
@@ -69,16 +69,19 @@ class TestCompressFileDir:
 
     def test_compress_and_decompress_file(self):
         fname = os.path.join(test_dir, "tempfile")
+
         for fmt in ["gz", "bz2"]:
             compress_file(fname, fmt)
             assert os.path.exists(fname + "." + fmt)
             assert not os.path.exists(fname)
+
             decompress_file(fname + "." + fmt)
             assert os.path.exists(fname)
             assert not os.path.exists(fname + "." + fmt)
-        with open(fname) as f:
-            txt = f.read()
-            assert txt == "hello world"
+
+            with open(fname) as f:
+                assert f.read() == "hello world"
+
         with pytest.raises(ValueError):
             compress_file("whatever", "badformat")
 
@@ -87,6 +90,30 @@ class TestCompressFileDir:
         assert decompress_file("non-existent.gz") is None
         assert decompress_file("non-existent.bz2") is None
 
+    def test_compress_and_decompress_with_target_dir(self):
+        fname = os.path.join(test_dir, "tempfile")
+        target_dir = os.path.join(test_dir, "temp_target_dir")
+
+        for fmt in ["gz", "bz2"]:
+            compress_file(fname, fmt, target_dir)
+            compressed_file_path = os.path.join(
+                target_dir, f"{os.path.basename(fname)}.{fmt}"
+            )
+            assert os.path.exists(compressed_file_path)
+            assert not os.path.exists(fname)
+
+            decompress_file(compressed_file_path, target_dir)
+            decompressed_file_path = os.path.join(target_dir, os.path.basename(fname))
+            assert os.path.exists(decompressed_file_path)
+            assert not os.path.exists(compressed_file_path)
+
+            # Reset temp file position
+            shutil.move(decompressed_file_path, fname)
+            shutil.rmtree(target_dir)
+
+            with open(fname) as f:
+                assert f.read() == "hello world"
+
     def teardown_method(self):
         os.remove(os.path.join(test_dir, "tempfile"))