Repository: Quantum-Accelerators/quacc (python)

Issue:
Unexpected scratch subdirectory behavior when the path is relative
### Details about the quacc environment

- **quacc version**: 0.6.11 and 0.6.10
- **Python version**: 3.9.18


### What is the issue?

I noticed this issue during the implementation of the nscf_job https://github.com/Quantum-Accelerators/quacc/pull/1840 

Because I have set up two virtual environment. 
One is py39 and another py39_develop.

In the py39, the version 0.6.7 of quacc was installed. After the static calculations, all files in $SCRATCH could be removed as expected. 
In the py39_develop, the quacc code was updated in line with the latest commitment (v0.6.11), I found there was always another $SCRATCH located in $SCRATCH. I found this issue has already existed in the released version 0.6.10.

### How can we easily reproduce the issue?

```python
from quacc.recipes.vasp.core import static_job
results = static_job(atoms, copy_files=None, **static_kwargs)
```


Pull request:
Raise a ValueError if RESULTS_DIR or SCRATCH_DIR is relative
## Summary of Changes

Raise a `ValueError` if `RESULTS_DIR` or `SCRATCH_DIR` is relative.

Closes #1858.

### Checklist

- [X] I have read the ["Guidelines" section](https://quantum-accelerators.github.io/quacc/dev/contributing.html#guidelines) of the contributing guide. Don't lie! 😉
- [X] My PR is on a custom branch and is _not_ named `main`.
- [X] I have added relevant, comprehensive [unit tests](https://quantum-accelerators.github.io/quacc/dev/contributing.html#unit-tests).

### Notes

- Your PR will likely not be merged without proper and thorough tests.
- If you are an external contributor, you will see a comment from [@buildbot-princeton](https://github.com/buildbot-princeton). This is solely for the maintainers.
- When your code is ready for review, ping one of the [active maintainers](https://quantum-accelerators.github.io/quacc/about/contributors.html#active-maintainers).


Patch:
diff --git a/src/quacc/recipes/espresso/phonons.py b/src/quacc/recipes/espresso/phonons.py
index d963a18e..01130468 100644
--- a/src/quacc/recipes/espresso/phonons.py
+++ b/src/quacc/recipes/espresso/phonons.py
@@ -376,7 +376,7 @@ def grid_phonon_flow(
     @subflow
     def _grid_phonon_subflow(
         ph_input_data: dict | None,
-        ph_init_job_results: str | Path,
+        ph_init_job_results: RunSchema,
         ph_job: Job,
         nblocks: int = 1,
     ) -> list[RunSchema]:
@@ -422,6 +422,7 @@ def grid_phonon_flow(
 
         return grid_results
 
+    job_params = job_params or {}
     relax_job_defaults = {
         "input_data": {
             "control": {"forc_conv_thr": 5.0e-5},
@@ -430,7 +431,7 @@ def grid_phonon_flow(
     }
     ph_init_job_defaults = recursive_dict_merge(
         {"input_data": {"inputph": {"lqdir": True, "only_init": True}}},
-        job_params.get("ph_job", {}),
+        job_params.get("ph_job"),
     )
     ph_job_defaults = {
         "input_data": {
@@ -439,7 +440,7 @@ def grid_phonon_flow(
     }
     ph_recover_job_defaults = recursive_dict_merge(
         {"input_data": {"inputph": {"recover": True, "lqdir": True}}},
-        job_params.get("ph_job", {}),
+        job_params.get("ph_job"),
     )
 
     calc_defaults = {
diff --git a/src/quacc/settings.py b/src/quacc/settings.py
index 318034d5..a295e129 100644
--- a/src/quacc/settings.py
+++ b/src/quacc/settings.py
@@ -190,7 +190,7 @@ class QuaccSettings(BaseSettings):
             "projwfc": "projwfc.x",
             "pp": "pp.x",
             "wannier90": "wannier90.x",
-            "fs": "fs.x"
+            "fs": "fs.x",
         },
         description="Name for each espresso binary.",
     )
@@ -430,19 +430,9 @@ class QuaccSettings(BaseSettings):
 
     # --8<-- [end:settings]
 
-    @field_validator("RESULTS_DIR", "SCRATCH_DIR")
-    @classmethod
-    def resolve_and_make_paths(cls, v: Optional[Path]) -> Optional[Path]:
-        """Resolve and make paths."""
-        if v is None:
-            return v
-
-        v = Path(os.path.expandvars(v)).expanduser().resolve()
-        if not v.exists():
-            v.mkdir(parents=True)
-        return v
-
     @field_validator(
+        "RESULTS_DIR",
+        "SCRATCH_DIR",
         "ESPRESSO_PRESET_DIR",
         "ESPRESSO_PSEUDO",
         "GAUSSIAN_CMD",
@@ -457,8 +447,21 @@ class QuaccSettings(BaseSettings):
     )
     @classmethod
     def expand_paths(cls, v: Optional[Path]) -> Optional[Path]:
-        """Expand ~/ in paths."""
-        return v.expanduser() if v is not None else v
+        """Expand ~/ and $ENV_VARS in paths."""
+        if v:
+            v = Path(os.path.expandvars(v)).expanduser()
+        return v
+
+    @field_validator("RESULTS_DIR", "SCRATCH_DIR")
+    @classmethod
+    def make_directories(cls, v: Optional[Path]) -> Optional[Path]:
+        """Make directories."""
+        if v:
+            if not v.is_absolute():
+                raise ValueError(f"{v} must be an absolute path.")
+            if not v.exists():
+                v.mkdir(parents=True)
+        return v
 
     @field_validator("STORE")
     def generate_store(cls, v: Union[dict[str, dict[str, Any]], Store]) -> Store:
diff --git a/src/quacc/wflow_tools/customizers.py b/src/quacc/wflow_tools/customizers.py
index 6451ab71..31e03de9 100644
--- a/src/quacc/wflow_tools/customizers.py
+++ b/src/quacc/wflow_tools/customizers.py
@@ -187,14 +187,14 @@ def customize_funcs(
 
     for i, func in enumerate(funcs):
         func_ = deepcopy(func)
-        if params := parameters.get("all"):
-            func_ = update_parameters(func_, params)
-        if params := parameters.get(names[i]):
-            func_ = update_parameters(func_, params)
         if "all" in decorators:
             func_ = redecorate(func_, decorators["all"])
         if names[i] in decorators:
             func_ = redecorate(func_, decorators[names[i]])
+        if params := parameters.get("all"):
+            func_ = update_parameters(func_, params)
+        if params := parameters.get(names[i]):
+            func_ = update_parameters(func_, params)
         updated_funcs.append(func_)
 
     return updated_funcs[0] if len(updated_funcs) == 1 else tuple(updated_funcs)


Test patch:
diff --git a/tests/core/settings/test_settings.py b/tests/core/settings/test_settings.py
index 7df66014..1d244357 100644
--- a/tests/core/settings/test_settings.py
+++ b/tests/core/settings/test_settings.py
@@ -1,6 +1,7 @@
 import os
 from pathlib import Path
 
+import pytest
 from ase.build import bulk
 from maggma.stores import MemoryStore
 
@@ -43,6 +44,13 @@ def test_results_dir(tmp_path, monkeypatch):
     os.remove("opt.traj")
 
 
+def test_bad_dir():
+    with pytest.raises(ValueError, match="must be an absolute path"):
+        SETTINGS.RESULTS_DIR = "bad_dir"
+    with pytest.raises(ValueError, match="must be an absolute path"):
+        SETTINGS.SCRATCH_DIR = "bad_dir"
+
+
 def test_env_var(monkeypatch, tmp_path):
     p = tmp_path / "my/scratch/dir"
     monkeypatch.setenv("QUACC_SCRATCH_DIR", p)
diff --git a/tests/core/wflow/test_customizers.py b/tests/core/wflow/test_customizers.py
index aaa0075a..33c5cd03 100644
--- a/tests/core/wflow/test_customizers.py
+++ b/tests/core/wflow/test_customizers.py
@@ -1,5 +1,6 @@
 import pytest
 
+from quacc import job
 from quacc.wflow_tools.customizers import customize_funcs
 
 
@@ -22,6 +23,15 @@ def test_basic_customizers():
     assert add_(1) == 5
     assert mult_(1) == 8
 
+    add_, mult_ = customize_funcs(
+        ["add", "mult"],
+        [add, mult],
+        parameters={"add": {"b": 2}, "mult": {"b": 2}},
+        decorators={"add": job(), "mult": job()},
+    )
+    assert add_(1) == 5
+    assert mult_(1) == 8
+
     add_, mult_ = customize_funcs(
         ["add", "mult"], [add, mult], parameters={"all": {"b": 2}}
     )
diff --git a/tests/covalent/test_emt_recipes.py b/tests/covalent/test_emt_recipes.py
index 33536541..5c21a4ee 100644
--- a/tests/covalent/test_emt_recipes.py
+++ b/tests/covalent/test_emt_recipes.py
@@ -4,16 +4,22 @@ ct = pytest.importorskip("covalent")
 
 from ase.build import bulk
 
+from quacc import job
+
 # from quacc import flow
 # from quacc.recipes.emt.core import relax_job
 from quacc.recipes.emt.slabs import bulk_to_slabs_flow  # skipcq: PYL-C0412
 
 
-def test_functools(tmp_path, monkeypatch):
+@pytest.mark.parametrize("job_decorators", [None, {"relax_job": job()}])
+def test_functools(tmp_path, monkeypatch, job_decorators):
     monkeypatch.chdir(tmp_path)
     atoms = bulk("Cu")
     dispatch_id = ct.dispatch(bulk_to_slabs_flow)(
-        atoms, job_params={"relax_job": {"opt_params": {"fmax": 0.1}}}, run_static=False
+        atoms,
+        run_static=False,
+        job_params={"relax_job": {"opt_params": {"fmax": 0.1}}},
+        job_decorators=job_decorators,
     )
     output = ct.get_result(dispatch_id, wait=True)
     assert output.status == "COMPLETED"
diff --git a/tests/dask/test_emt_recipes.py b/tests/dask/test_emt_recipes.py
index 004f72a8..0bc04420 100644
--- a/tests/dask/test_emt_recipes.py
+++ b/tests/dask/test_emt_recipes.py
@@ -6,18 +6,22 @@ pytest.importorskip("distributed")
 from ase.build import bulk
 from dask.distributed import get_client
 
-from quacc import flow
+from quacc import flow, job
 from quacc.recipes.emt.core import relax_job  # skipcq: PYL-C0412
 from quacc.recipes.emt.slabs import bulk_to_slabs_flow  # skipcq: PYL-C0412
 
 client = get_client()
 
 
-def test_functools(tmp_path, monkeypatch):
+@pytest.mark.parametrize("job_decorators", [None, {"relax_job": job()}])
+def test_functools(tmp_path, monkeypatch, job_decorators):
     monkeypatch.chdir(tmp_path)
     atoms = bulk("Cu")
     delayed = bulk_to_slabs_flow(
-        atoms, job_params={"relax_job": {"opt_params": {"fmax": 0.1}}}, run_static=False
+        atoms,
+        run_static=False,
+        job_params={"relax_job": {"opt_params": {"fmax": 0.1}}},
+        job_decorators=job_decorators,
     )
     result = client.compute(delayed).result()
     assert len(result) == 4
diff --git a/tests/parsl/test_emt_recipes.py b/tests/parsl/test_emt_recipes.py
index c91048b6..a892fc82 100644
--- a/tests/parsl/test_emt_recipes.py
+++ b/tests/parsl/test_emt_recipes.py
@@ -4,7 +4,7 @@ parsl = pytest.importorskip("parsl")
 
 from ase.build import bulk
 
-from quacc import SETTINGS
+from quacc import SETTINGS, job
 from quacc.recipes.emt.core import relax_job  # skipcq: PYL-C0412
 from quacc.recipes.emt.slabs import bulk_to_slabs_flow  # skipcq: PYL-C0412
 
@@ -15,14 +15,18 @@ DEFAULT_SETTINGS = SETTINGS.model_copy()
 
 
 @pytest.mark.parametrize("chdir", [True, False])
-def test_functools(tmp_path, monkeypatch, chdir):
+@pytest.mark.parametrize("job_decorators", [None, {"relax_job": job()}])
+def test_functools(tmp_path, monkeypatch, chdir, job_decorators):
     monkeypatch.chdir(tmp_path)
 
     SETTINGS.CHDIR = chdir
 
     atoms = bulk("Cu")
     result = bulk_to_slabs_flow(
-        atoms, job_params={"relax_job": {"opt_params": {"fmax": 0.1}}}, run_static=False
+        atoms,
+        run_static=False,
+        job_params={"relax_job": {"opt_params": {"fmax": 0.1}}},
+        job_decorators=job_decorators,
     ).result()
     assert len(result) == 4
     assert "atoms" in result[-1]
diff --git a/tests/prefect/test_emt_recipes.py b/tests/prefect/test_emt_recipes.py
index cae17667..4c73480a 100644
--- a/tests/prefect/test_emt_recipes.py
+++ b/tests/prefect/test_emt_recipes.py
@@ -4,16 +4,20 @@ prefect = pytest.importorskip("prefect")
 
 from ase.build import bulk
 
-from quacc import flow
+from quacc import flow, job
 from quacc.recipes.emt.core import relax_job
 from quacc.recipes.emt.slabs import bulk_to_slabs_flow  # skipcq: PYL-C0412
 
 
-def test_functools(tmp_path, monkeypatch):
+@pytest.mark.parametrize("job_decorators", [None, {"relax_job": job()}])
+def test_functools(tmp_path, monkeypatch, job_decorators):
     monkeypatch.chdir(tmp_path)
     atoms = bulk("Cu")
     output = bulk_to_slabs_flow(
-        atoms, job_params={"relax_job": {"opt_params": {"fmax": 0.1}}}, run_static=False
+        atoms,
+        run_static=False,
+        job_params={"relax_job": {"opt_params": {"fmax": 0.1}}},
+        job_decorators=job_decorators,
     )
     result = [future.result() for future in output]
     assert len(result) == 4
diff --git a/tests/redun/test_emt_recipes.py b/tests/redun/test_emt_recipes.py
index 35130974..fc439290 100644
--- a/tests/redun/test_emt_recipes.py
+++ b/tests/redun/test_emt_recipes.py
@@ -9,19 +9,21 @@ def scheduler():
     return redun.Scheduler()
 
 
-from quacc import flow
+from quacc import flow, job
 from quacc.recipes.emt.core import relax_job
 from quacc.recipes.emt.slabs import bulk_to_slabs_flow  # skipcq: PYL-C0412
 
 
-def test_functools(tmp_path, monkeypatch, scheduler):
+@pytest.mark.parametrize("job_decorators", [None, {"relax_job": job()}])
+def test_functools(tmp_path, monkeypatch, scheduler, job_decorators):
     monkeypatch.chdir(tmp_path)
     atoms = bulk("Cu")
     result = scheduler.run(
         bulk_to_slabs_flow(
             atoms,
-            job_params={"relax_job": {"opt_params": {"fmax": 0.1}}},
             run_static=False,
+            job_params={"relax_job": {"opt_params": {"fmax": 0.1}}},
+            job_decorators=job_decorators,
         )
     )
     assert len(result) == 4