Repository: JonathonReinhart/scuba
Pull request #226 (2023-09-13 07:28:51)

Use path objects throughout
[`pathlib.Path`](https://docs.python.org/3/library/pathlib.html), added in Python 3.4, is a welcome improvement to many places in Scuba, making it clear when objects are paths, and making many path manipulation actions much simpler.


Patch:
diff --git a/scuba/__main__.py b/scuba/__main__.py
index de7820a..79b9c43 100644
--- a/scuba/__main__.py
+++ b/scuba/__main__.py
@@ -8,6 +8,7 @@ import sys
 import shlex
 import itertools
 import argparse
+from pathlib import Path
 from typing import Any, Optional, Sequence
 
 try:
@@ -131,7 +132,7 @@ def run_scuba(scuba_args: argparse.Namespace) -> int:
         # .scuba.yml is allowed to be missing if --image was given.
         if not scuba_args.image:
             raise
-        top_path, top_rel, config = os.getcwd(), "", ScubaConfig()
+        top_path, top_rel, config = Path.cwd(), Path(), ScubaConfig()
 
     # Set up scuba Docker invocation
     dive = ScubaDive(
diff --git a/scuba/config.py b/scuba/config.py
index 80846bd..311963a 100644
--- a/scuba/config.py
+++ b/scuba/config.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 import dataclasses
 import os
+from pathlib import Path
 import re
 import shlex
 from typing import Any, List, Dict, Optional, TextIO, Tuple, Type, TypeVar, Union
@@ -55,17 +56,17 @@ class OverrideStr(str, OverrideMixin):
 
 # http://stackoverflow.com/a/9577670
 class Loader(yaml.SafeLoader):
-    _root: str  # directory containing the loaded document
-    _cache: Dict[str, Any]  # document path => document
+    _root: Path  # directory containing the loaded document
+    _cache: Dict[Path, Any]  # document path => document
 
     def __init__(self, stream: TextIO):
         if not hasattr(self, "_root"):
-            self._root = os.path.split(stream.name)[0]
+            self._root = Path(stream.name).parent
         self._cache = dict()
         super().__init__(stream)
 
     @staticmethod
-    def _rooted_loader(root: str) -> Type[Loader]:
+    def _rooted_loader(root: Path) -> Type[Loader]:
         """Get a Loader class with _root set to root"""
 
         class RootedLoader(Loader):
@@ -103,12 +104,12 @@ class Loader(yaml.SafeLoader):
         filename, key = parts
 
         # path is relative to the current YAML document
-        path = os.path.join(self._root, filename)
+        path = self._root / filename
 
         # Load the other YAML document
         doc = self._cache.get(path)
         if not doc:
-            with open(path, "r") as f:
+            with path.open("r") as f:
                 doc = yaml.load(f, self.__class__)
                 self._cache[path] = doc
 
@@ -157,7 +158,7 @@ Loader.add_constructor("!from_yaml", Loader.from_yaml)
 Loader.add_constructor("!override", Loader.override)
 
 
-def find_config() -> Tuple[str, str, ScubaConfig]:
+def find_config() -> Tuple[Path, Path, ScubaConfig]:
     """Search up the directory hierarchy for .scuba.yml
 
     Returns: path, rel, config on success, or None if not found
@@ -167,15 +168,14 @@ def find_config() -> Tuple[str, str, ScubaConfig]:
         config  The loaded configuration
     """
     cross_fs = "SCUBA_DISCOVERY_ACROSS_FILESYSTEM" in os.environ
-    path = os.getcwd()
+    path = Path.cwd()
 
-    rel = ""
     while True:
-        cfg_path = os.path.join(path, SCUBA_YML)
-        if os.path.exists(cfg_path):
-            return path, rel, load_config(cfg_path)
+        cfg_path = path / SCUBA_YML
+        if cfg_path.exists():
+            return path, Path.cwd().relative_to(path), load_config(cfg_path)
 
-        if not cross_fs and os.path.ismount(path):
+        if not cross_fs and path.is_mount():
             raise ConfigNotFoundError(
                 f"{SCUBA_YML} not found here or any parent up to mount point {path}"
                 "\nStopping at filesystem boundary"
@@ -183,15 +183,12 @@ def find_config() -> Tuple[str, str, ScubaConfig]:
             )
 
         # Traverse up directory hierarchy
-        path, rest = os.path.split(path)
+        path, rest = path.parent, path.name
         if not rest:
             raise ConfigNotFoundError(
                 f"{SCUBA_YML} not found here or any parent directories"
             )
 
-        # Accumulate the relative path back to where we started
-        rel = os.path.join(rest, rel)
-
 
 def _process_script_node(node: CfgNode, name: str) -> List[str]:
     """Process a script-type node
@@ -317,7 +314,7 @@ def _get_str(data: CfgData, key: str, default: Optional[str] = None) -> Optional
     return _get_typed_val(data, key, str, default)
 
 
-def _get_dict(data: CfgData, key: str) -> Optional[dict]:
+def _get_dict(data: CfgData, key: str) -> Optional[dict[str, Any]]:
     return _get_typed_val(data, key, dict)
 
 
@@ -326,19 +323,19 @@ def _get_delimited_str_list(data: CfgData, key: str, sep: str) -> List[str]:
     return s.split(sep) if s else []
 
 
-def _get_volumes(data: CfgData) -> Optional[Dict[str, ScubaVolume]]:
+def _get_volumes(data: CfgData) -> Optional[Dict[Path, ScubaVolume]]:
     voldata = _get_dict(data, "volumes")
     if voldata is None:
         return None
 
     vols = {}
-    for cpath, v in voldata.items():
-        cpath = _expand_path(cpath)
+    for cpath_str, v in voldata.items():
+        cpath = _expand_path(cpath_str)
         vols[cpath] = ScubaVolume.from_dict(cpath, v)
     return vols
 
 
-def _expand_path(in_str: str) -> str:
+def _expand_path(in_str: str) -> Path:
     try:
         output = expand_env_vars(in_str)
     except KeyError as ke:
@@ -351,17 +348,17 @@ def _expand_path(in_str: str) -> str:
             f"Unable to expand string '{in_str}' due to parsing errors"
         ) from ve
 
-    return output
+    return Path(output)
 
 
 @dataclasses.dataclass(frozen=True)
 class ScubaVolume:
-    container_path: str
-    host_path: str  # TODO: Optional for anonymous volume
+    container_path: Path
+    host_path: Path  # TODO: Optional for anonymous volume
     options: List[str] = dataclasses.field(default_factory=list)
 
     @classmethod
-    def from_dict(cls, cpath: str, node: CfgNode) -> ScubaVolume:
+    def from_dict(cls, cpath: Path, node: CfgNode) -> ScubaVolume:
         # Treat a null node as an empty dict
         if node is None:
             node = {}
@@ -406,7 +403,7 @@ class ScubaAlias:
     shell: Optional[str] = None
     as_root: bool = False
     docker_args: Optional[List[str]] = None
-    volumes: Optional[Dict[str, ScubaVolume]] = None
+    volumes: Optional[Dict[Path, ScubaVolume]] = None
 
     @classmethod
     def from_dict(cls, name: str, node: CfgNode) -> ScubaAlias:
@@ -434,7 +431,7 @@ class ScubaConfig:
     shell: str
     entrypoint: Optional[str]
     docker_args: Optional[List[str]]  # TODO: drop Optional?
-    volumes: Optional[Dict[str, ScubaVolume]]  # TODO: drop Optional? Dict?
+    volumes: Optional[Dict[Path, ScubaVolume]]  # TODO: drop Optional? Dict?
     aliases: Dict[str, ScubaAlias]
     hooks: Dict[str, List[str]]
     environment: Environment
@@ -494,9 +491,9 @@ class ScubaConfig:
         return self._image
 
 
-def load_config(path: str) -> ScubaConfig:
+def load_config(path: Path) -> ScubaConfig:
     try:
-        with open(path, "r") as f:
+        with path.open("r") as f:
             data = yaml.load(f, Loader)
     except IOError as e:
         raise ConfigError(f"Error opening {SCUBA_YML}: {e}")
diff --git a/scuba/dockerutil.py b/scuba/dockerutil.py
index 514de34..a5207de 100644
--- a/scuba/dockerutil.py
+++ b/scuba/dockerutil.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 import subprocess
 import json
 from typing import Any, Dict, IO, Optional, Sequence, Union
+from pathlib import Path
 
 # https://github.com/python/typeshed/blob/main/stdlib/subprocess.pyi
 _CMD = Union[str, bytes, Sequence[Union[str, bytes]]]
@@ -131,8 +132,8 @@ def get_image_entrypoint(image: str) -> Optional[Sequence[str]]:
 
 
 def make_vol_opt(
-    hostdir: str,
-    contdir: str,
+    hostdir: Path,
+    contdir: Path,
     options: Optional[Sequence[str]] = None,
 ) -> str:
     """Generate a docker volume option"""
diff --git a/scuba/scuba.py b/scuba/scuba.py
index f107623..0c4f989 100644
--- a/scuba/scuba.py
+++ b/scuba/scuba.py
@@ -7,6 +7,7 @@ import sys
 import tempfile
 from grp import getgrgid
 from io import StringIO
+from pathlib import Path
 from pwd import getpwuid
 from typing import cast, Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
 from typing import TextIO
@@ -18,7 +19,7 @@ from .dockerutil import get_image_entrypoint
 from .dockerutil import make_vol_opt
 from .utils import shell_quote_cmd, flatten_list, get_umask, writeln
 
-VolumeTuple = Tuple[str, str, List[str]]
+VolumeTuple = Tuple[Path, Path, List[str]]
 
 
 class ScubaError(Exception):
@@ -38,8 +39,8 @@ class ScubaDive:
         self,
         user_command: List[str],
         config: ScubaConfig,
-        top_path: str,
-        top_rel: str,
+        top_path: Path,
+        top_rel: Path,
         docker_args: Optional[List[str]] = None,
         env: Optional[Dict[str, str]] = None,
         as_root: bool = False,
@@ -59,7 +60,7 @@ class ScubaDive:
         self.volumes = []
         self.options = []
         self.docker_args = docker_args or []
-        self.workdir: Optional[str] = None
+        self.workdir: Optional[Path] = None
 
         self.__scubadir_hostpath: Optional[str] = None
         self.__scubadir_contpath: Optional[str] = None
@@ -69,9 +70,9 @@ class ScubaDive:
         self.add_volume(top_path, top_path)
 
         # ...and set the working dir relative to it
-        self.set_workdir(os.path.join(top_path, top_rel))
+        self.set_workdir(top_path / top_rel)
 
-        self.add_env("SCUBA_ROOT", top_path)
+        self.add_env("SCUBA_ROOT", str(top_path))
 
         try:
             # Process any aliases
@@ -129,7 +130,7 @@ class ScubaDive:
             for val in vals or ():
                 writeln(s, f"{indent * (level + 1)}{val}")
 
-        def writescl(name: str, val: Union[None, bool, float, str]) -> None:
+        def writescl(name: str, val: Union[None, bool, float, str, Path]) -> None:
             writeln(s, f"{indent * level}{name + ':':<14s}{val}")
 
         writeln(s, "ScubaDive")
@@ -167,11 +168,13 @@ class ScubaDive:
 
     def add_volume(
         self,
-        hostpath: str,
-        contpath: str,
+        hostpath: Union[Path, str],
+        contpath: Union[Path, str],
         options: Optional[List[str]] = None,
     ) -> None:
         """Add a volume (bind-mount) to the docker run invocation"""
+        hostpath = Path(hostpath)
+        contpath = Path(contpath)
         if options is None:
             options = []
         self.volumes.append((hostpath, contpath, options))
@@ -188,7 +191,7 @@ class ScubaDive:
             return
 
         for vol in self.context.volumes.values():
-            if os.path.exists(vol.host_path):
+            if vol.host_path.exists():
                 continue
 
             try:
@@ -204,7 +207,7 @@ class ScubaDive:
         """Add another option to the docker run invocation"""
         self.options.append(option)
 
-    def set_workdir(self, workdir: str) -> None:
+    def set_workdir(self, workdir: Path) -> None:
         self.workdir = workdir
 
     def __locate_scubainit(self) -> str:
@@ -375,7 +378,7 @@ class ScubaDive:
             args.append(vol.get_vol_opt())
 
         if self.workdir:
-            args += ["-w", self.workdir]
+            args += ["-w", str(self.workdir)]
 
         args += self.options
 
@@ -400,7 +403,7 @@ class ScubaDive:
 class ScubaContext:
     image: str
     environment: Dict[str, str]  # key: value
-    volumes: Dict[str, ScubaVolume]
+    volumes: Dict[Path, ScubaVolume]
     shell: str
     docker_args: List[str]
     script: Optional[List[str]] = None  # TODO: drop Optional?
@@ -432,7 +435,7 @@ class ScubaContext:
         entrypoint = cfg.entrypoint
         environment = copy.copy(cfg.environment)
         docker_args = copy.copy(cfg.docker_args) or []
-        volumes: Dict[str, ScubaVolume] = copy.copy(cfg.volumes or {})
+        volumes: Dict[Path, ScubaVolume] = copy.copy(cfg.volumes or {})
         as_root = False
 
         if command:


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
index 0ed066a..8c1bc06 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -5,11 +5,16 @@ import pytest
 import logging
 import os
 from os.path import join
+from pathlib import Path
 from shutil import rmtree
 
 import scuba.config
 
 
+def load_config() -> scuba.config.ScubaConfig:
+    return scuba.config.load_config(Path(".scuba.yml"))
+
+
 class TestCommonScriptSchema:
     def test_simple(self) -> None:
         """Simple form: value is a string"""
@@ -106,14 +111,14 @@ class TestConfig:
 
     def _invalid_config(self, match=None):
         with pytest.raises(scuba.config.ConfigError, match=match) as e:
-            scuba.config.load_config(".scuba.yml")
+            load_config()
 
     def test_load_config_no_image(self) -> None:
         """load_config raises ConfigError if the config is empty and image is referenced"""
         with open(".scuba.yml", "w") as f:
             pass
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         with pytest.raises(scuba.config.ConfigError):
             img = config.image
 
@@ -130,7 +135,7 @@ class TestConfig:
         with open(".scuba.yml", "w") as f:
             f.write("image: bosybux\n")
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.image == "bosybux"
 
     def test_load_config_with_aliases(self) -> None:
@@ -141,7 +146,7 @@ class TestConfig:
             f.write("  foo: bar\n")
             f.write("  snap: crackle pop\n")
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.image == "bosybux"
         assert len(config.aliases) == 2
         assert config.aliases["foo"].script == ["bar"]
@@ -164,7 +169,7 @@ class TestConfig:
         with open(".scuba.yml", "w") as f:
             f.write("image: !from_yaml .gitlab.yml image\n")
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.image == "dummian:8.2"
 
     def test_load_config_image_from_yaml_nested_keys(self) -> None:
@@ -177,7 +182,7 @@ class TestConfig:
         with open(".scuba.yml", "w") as f:
             f.write("image: !from_yaml .gitlab.yml somewhere.down.here\n")
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.image == "dummian:8.2"
 
     def test_load_config_image_from_yaml_nested_keys_with_escaped_characters(
@@ -192,7 +197,7 @@ class TestConfig:
         with open(".scuba.yml", "w") as f:
             f.write('image: !from_yaml .gitlab.yml "\\.its.somewhere\\.down.here"\n')
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.image == "dummian:8.2"
 
     def test_load_config_from_yaml_cached_file(self) -> None:
@@ -212,13 +217,13 @@ class TestConfig:
             f.write("    image:  !from_yaml .gitlab.yml three\n")
             f.write("    script: ugh\n")
 
-        with mock_open() as m:
-            config = scuba.config.load_config(".scuba.yml")
+        with mock.patch.object(Path, "open", autospec=True, side_effect=Path.open) as m:
+            config = load_config()
 
         # Assert that .gitlab.yml was only opened once
         assert m.mock_calls == [
-            mock.call(".scuba.yml", "r"),
-            mock.call(".gitlab.yml", "r"),
+            mock.call(Path(".scuba.yml"), "r"),
+            mock.call(Path(".gitlab.yml"), "r"),
         ]
 
     def test_load_config_image_from_yaml_nested_key_missing(self) -> None:
@@ -247,7 +252,7 @@ class TestConfig:
         with open(".scuba.yml", "w") as f:
             f.write("image: !from_yaml .gitlab.yml 𝕦𝕟𝕚𝕔𝕠𝕕𝕖\n")
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.image == "𝕨𝕠𝕣𝕜𝕤:𝕠𝕜"
 
     def test_load_config_image_from_yaml_missing_arg(self) -> None:
@@ -268,7 +273,7 @@ class TestConfig:
 
         pat = "could not determine a constructor for the tag.*python/object/apply"
         with pytest.raises(scuba.config.ConfigError, match=pat) as ctx:
-            scuba.config.load_config(".scuba.yml")
+            load_config()
 
     def test_load_config_safe(self) -> None:
         """load_config safely loads yaml"""
@@ -299,7 +304,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
 
         assert config.hooks.get("root") == [
             'echo "This runs before we switch users"',
@@ -372,7 +377,7 @@ class TestConfig:
         monkeypatch.setenv("EXTERNAL", "Outside world")
         monkeypatch.delenv("EXTERNAL_NOTSET", raising=False)
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
 
         expect = dict(
             FOO="This is foo",
@@ -408,7 +413,7 @@ class TestConfig:
         monkeypatch.setenv("EXTERNAL", "Outside world")
         monkeypatch.delenv("EXTERNAL_NOTSET", raising=False)
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
 
         expect = dict(
             FOO="This is foo",
@@ -437,7 +442,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
 
         assert config.aliases["al"].environment == dict(
             FOO="Overridden",
@@ -456,7 +461,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.entrypoint is None
 
     def test_entrypoint_null(self) -> None:
@@ -469,7 +474,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.entrypoint == ""  # Null => empty string
 
     def test_entrypoint_invalid(self) -> None:
@@ -494,7 +499,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.entrypoint == ""
 
     def test_entrypoint_set(self) -> None:
@@ -507,7 +512,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.entrypoint == "my_ep"
 
     def test_alias_entrypoint_null(self) -> None:
@@ -525,7 +530,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.aliases["testalias"].entrypoint == ""  # Null => empty string
 
     def test_alias_entrypoint_empty_string(self) -> None:
@@ -543,7 +548,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.aliases["testalias"].entrypoint == ""
 
     def test_alias_entrypoint(self) -> None:
@@ -561,7 +566,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.aliases["testalias"].entrypoint == "use_this_ep"
 
     ############################################################################
@@ -576,7 +581,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.docker_args is None
 
     def test_docker_args_invalid(self) -> None:
@@ -601,7 +606,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.docker_args == []
 
     def test_docker_args_set_empty_string(self) -> None:
@@ -614,7 +619,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.docker_args == []  # '' -> [] after shlex.split()
 
     def test_docker_args_set(self) -> None:
@@ -627,7 +632,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.docker_args == ["--privileged"]
 
     def test_docker_args_set_multi(self) -> None:
@@ -640,7 +645,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.docker_args == ["--privileged", "-v", "/tmp/:/tmp/"]
 
     def test_alias_docker_args_null(self) -> None:
@@ -658,7 +663,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.aliases["testalias"].docker_args == []
 
     def test_alias_docker_args_empty_string(self) -> None:
@@ -676,7 +681,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.aliases["testalias"].docker_args == []
 
     def test_alias_docker_args_set(self) -> None:
@@ -694,7 +699,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.aliases["testalias"].docker_args == ["-v", "/tmp/:/tmp/"]
 
     def test_alias_docker_args_override(self) -> None:
@@ -712,7 +717,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.aliases["testalias"].docker_args == ["-v", "/tmp/:/tmp/"]
         assert isinstance(
             config.aliases["testalias"].docker_args, scuba.config.OverrideMixin
@@ -733,7 +738,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.aliases["testalias"].docker_args == []
         assert isinstance(
             config.aliases["testalias"].docker_args, scuba.config.OverrideMixin
@@ -757,7 +762,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.aliases["testalias"].docker_args == ["-v", "/tmp/:/tmp/"]
         assert isinstance(
             config.aliases["testalias"].docker_args, scuba.config.OverrideMixin
@@ -781,7 +786,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.aliases["testalias"].docker_args == ["-v", "/tmp/:/tmp/"]
         assert isinstance(
             config.aliases["testalias"].docker_args, scuba.config.OverrideMixin
@@ -799,7 +804,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.volumes is None
 
     def test_volumes_null(self) -> None:
@@ -812,7 +817,7 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.volumes == None
 
     def test_volumes_invalid(self) -> None:
@@ -883,13 +888,11 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         assert config.volumes is not None
         assert len(config.volumes) == 1
 
-        v = config.volumes["/cpath"]
-        assert v.container_path == "/cpath"
-        assert v.host_path == "/hpath"
+        assert_vol(config.volumes, "/cpath", "/hpath")
 
     def test_volumes_complex(self) -> None:
         """volumes can be set using the complex form"""
@@ -907,28 +910,14 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         vols = config.volumes
         assert vols is not None
         assert len(vols) == 3
 
-        v = vols["/foo"]
-        assert isinstance(v, scuba.config.ScubaVolume)
-        assert v.container_path == "/foo"
-        assert v.host_path == "/host/foo"
-        assert v.options == []
-
-        v = vols["/bar"]
-        assert isinstance(v, scuba.config.ScubaVolume)
-        assert v.container_path == "/bar"
-        assert v.host_path == "/host/bar"
-        assert v.options == []
-
-        v = vols["/snap"]
-        assert isinstance(v, scuba.config.ScubaVolume)
-        assert v.container_path == "/snap"
-        assert v.host_path == "/host/snap"
-        assert v.options == ["z", "ro"]
+        assert_vol(vols, "/foo", "/host/foo")
+        assert_vol(vols, "/bar", "/host/bar")
+        assert_vol(vols, "/snap", "/host/snap", ["z", "ro"])
 
     def test_alias_volumes_set(self) -> None:
         """docker_args can be set via alias"""
@@ -948,22 +937,13 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         vols = config.aliases["testalias"].volumes
         assert vols is not None
         assert len(vols) == 2
 
-        v = vols["/foo"]
-        assert isinstance(v, scuba.config.ScubaVolume)
-        assert v.container_path == "/foo"
-        assert v.host_path == "/host/foo"
-        assert v.options == []
-
-        v = vols["/bar"]
-        assert isinstance(v, scuba.config.ScubaVolume)
-        assert v.container_path == "/bar"
-        assert v.host_path == "/host/bar"
-        assert v.options == ["z", "ro"]
+        assert_vol(vols, "/foo", "/host/foo")
+        assert_vol(vols, "/bar", "/host/bar", ["z", "ro"])
 
     def test_volumes_with_env_vars_simple(self, monkeypatch) -> None:
         """volume definitions can contain environment variables"""
@@ -978,16 +958,12 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         vols = config.volumes
         assert vols is not None
         assert len(vols) == 1
 
-        v = list(vols.values())[0]
-        assert isinstance(v, scuba.config.ScubaVolume)
-        assert v.container_path == "/bar/baz/foo"
-        assert v.host_path == "/moo/doo/foo"
-        assert v.options == []
+        assert_vol(vols, "/bar/baz/foo", "/moo/doo/foo")
 
     def test_volumes_with_env_vars_complex(self, monkeypatch) -> None:
         """complex volume definitions can contain environment variables"""
@@ -1009,28 +985,16 @@ class TestConfig:
                 """
             )
 
-        config = scuba.config.load_config(".scuba.yml")
+        config = load_config()
         vols = config.volumes
         assert vols is not None
         assert len(vols) == 3
 
-        v = vols["/home/testuser/.config"]
-        assert isinstance(v, scuba.config.ScubaVolume)
-        assert v.container_path == "/home/testuser/.config"
-        assert v.host_path == "/home/testuser/.config"
-        assert v.options == []
-
-        v = vols["/tmp/"]
-        assert isinstance(v, scuba.config.ScubaVolume)
-        assert v.container_path == "/tmp/"
-        assert v.host_path == "/home/testuser/scuba/myproject/tmp"
-        assert v.options == []
-
-        v = vols["/var/spool/mail/container"]
-        assert isinstance(v, scuba.config.ScubaVolume)
-        assert v.container_path == "/var/spool/mail/container"
-        assert v.host_path == "/var/spool/mail/testuser"
-        assert v.options == ["z", "ro"]
+        assert_vol(vols, "/home/testuser/.config", "/home/testuser/.config")
+        assert_vol(vols, "/tmp", "/home/testuser/scuba/myproject/tmp")
+        assert_vol(
+            vols, "/var/spool/mail/container", "/var/spool/mail/testuser", ["z", "ro"]
+        )
 
     def test_volumes_with_invalid_env_vars(self, monkeypatch) -> None:
         """Volume definitions cannot include unset env vars"""
diff --git a/tests/test_dockerutil.py b/tests/test_dockerutil.py
index c67b589..01c4b12 100644
--- a/tests/test_dockerutil.py
+++ b/tests/test_dockerutil.py
@@ -2,6 +2,7 @@
 from unittest import mock
 import pytest
 
+from pathlib import Path
 import subprocess
 from typing import Sequence
 from .const import *
@@ -106,15 +107,21 @@ def test_get_image_entrypoint__none() -> None:
 
 
 def test_make_vol_opt_no_opts() -> None:
-    assert uut.make_vol_opt("/hostdir", "/contdir") == "--volume=/hostdir:/contdir"
+    assert (
+        uut.make_vol_opt(Path("/hostdir"), Path("/contdir"))
+        == "--volume=/hostdir:/contdir"
+    )
 
 
 def test_make_vol_opt_empty_opts() -> None:
-    assert uut.make_vol_opt("/hostdir", "/contdir", []) == "--volume=/hostdir:/contdir"
+    assert (
+        uut.make_vol_opt(Path("/hostdir"), Path("/contdir"), [])
+        == "--volume=/hostdir:/contdir"
+    )
 
 
 def test_make_vol_opt_multi_opts() -> None:
     assert (
-        uut.make_vol_opt("/hostdir", "/contdir", ["ro", "z"])
+        uut.make_vol_opt(Path("/hostdir"), Path("/contdir"), ["ro", "z"])
         == "--volume=/hostdir:/contdir:ro,z"
     )
diff --git a/tests/test_scuba.py b/tests/test_scuba.py
index ab48593..4aff2e6 100644
--- a/tests/test_scuba.py
+++ b/tests/test_scuba.py
@@ -1,5 +1,6 @@
 import pytest
 import shlex
+from .utils import assert_vol
 
 from scuba.config import ScubaConfig, ConfigError, OverrideStr
 from scuba.scuba import ScubaContext
@@ -340,13 +341,8 @@ class TestScubaContext:
         vols = result.volumes
         assert len(vols) == 2
 
-        v = vols["/foo"]
-        assert v.container_path == "/foo"
-        assert v.host_path == "/host/foo"
-
-        v = vols["/bar"]
-        assert v.container_path == "/bar"
-        assert v.host_path == "/host/bar"
+        assert_vol(vols, "/foo", "/host/foo")
+        assert_vol(vols, "/bar", "/host/bar")
 
     def test_process_command_alias_updates_volumes(self) -> None:
         """aliases can extend the volumes"""
@@ -370,6 +366,4 @@ class TestScubaContext:
         vols = result.volumes
         assert len(vols) == 1
 
-        v = vols["/foo"]
-        assert v.container_path == "/foo"
-        assert v.host_path == "/alternate/foo"
+        assert_vol(vols, "/foo", "/alternate/foo")
diff --git a/tests/utils.py b/tests/utils.py
index 8edbb76..ba329e5 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -6,9 +6,11 @@ import shutil
 import unittest
 import logging
 from pathlib import Path
-from typing import Any, Sequence, Union
+from typing import Any, Dict, List, Sequence, Union
 from unittest import mock
 
+from scuba.config import ScubaVolume
+
 PathStr = Union[Path, str]
 
 
@@ -26,21 +28,27 @@ def assert_str_equalish(exp: Any, act: Any) -> None:
     assert exp == act
 
 
+def assert_vol(
+    vols: Dict[Path, ScubaVolume],
+    cpath_str: str,
+    hpath_str: str,
+    options: List[str] = [],
+) -> None:
+    cpath = Path(cpath_str)
+    hpath = Path(hpath_str)
+    v = vols[cpath]
+    assert isinstance(v, ScubaVolume)
+    assert v.container_path == cpath
+    assert v.host_path == hpath
+    assert v.options == options
+
+
 def make_executable(path: PathStr) -> None:
     mode = os.stat(path).st_mode
     mode |= (mode & 0o444) >> 2  # copy R bits to X
     os.chmod(path, mode)
 
 
-def mock_open():
-    real_open = open
-
-    def mocked_open(*args, **kwargs):
-        return real_open(*args, **kwargs)
-
-    return mock.patch("builtins.open", side_effect=mocked_open)
-
-
 # http://stackoverflow.com/a/8389373/119527
 class PseudoTTY:
     def __init__(self, underlying):