config/findpaths: convert from py.path.local to pathlib

This commit is contained in:
Ran Benita 2020-08-03 17:46:35 +03:00
parent 9e55288ba4
commit 70f3ad1c1f
4 changed files with 222 additions and 171 deletions

View File

@ -1006,12 +1006,15 @@ class Config:
ns, unknown_args = self._parser.parse_known_and_unknown_args(
args, namespace=copy.copy(self.option)
)
self.rootdir, self.inifile, self.inicfg = determine_setup(
rootpath, inipath, inicfg = determine_setup(
ns.inifilename,
ns.file_or_dir + unknown_args,
rootdir_cmd_arg=ns.rootdir or None,
config=self,
)
self.rootdir = py.path.local(str(rootpath))
self.inifile = py.path.local(str(inipath)) if inipath else None
self.inicfg = inicfg
self._parser.extra_info["rootdir"] = self.rootdir
self._parser.extra_info["inifile"] = self.inifile
self._parser.addini("addopts", "extra command line options", "args")

View File

@ -1,23 +1,28 @@
import itertools
import os
import sys
from typing import Dict
from typing import Iterable
from typing import List
from typing import Optional
from typing import Sequence
from typing import Tuple
from typing import Union
import iniconfig
import py
from .exceptions import UsageError
from _pytest.compat import TYPE_CHECKING
from _pytest.outcomes import fail
from _pytest.pathlib import absolutepath
from _pytest.pathlib import commonpath
from _pytest.pathlib import Path
if TYPE_CHECKING:
from . import Config
def _parse_ini_config(path: py.path.local) -> iniconfig.IniConfig:
def _parse_ini_config(path: Path) -> iniconfig.IniConfig:
"""Parse the given generic '.ini' file using legacy IniConfig parser, returning
the parsed object.
@ -30,7 +35,7 @@ def _parse_ini_config(path: py.path.local) -> iniconfig.IniConfig:
def load_config_dict_from_file(
filepath: py.path.local,
filepath: Path,
) -> Optional[Dict[str, Union[str, List[str]]]]:
"""Load pytest configuration from the given file path, if supported.
@ -38,18 +43,18 @@ def load_config_dict_from_file(
"""
# Configuration from ini files are obtained from the [pytest] section, if present.
if filepath.ext == ".ini":
if filepath.suffix == ".ini":
iniconfig = _parse_ini_config(filepath)
if "pytest" in iniconfig:
return dict(iniconfig["pytest"].items())
else:
# "pytest.ini" files are always the source of configuration, even if empty.
if filepath.basename == "pytest.ini":
if filepath.name == "pytest.ini":
return {}
# '.cfg' files are considered if they contain a "[tool:pytest]" section.
elif filepath.ext == ".cfg":
elif filepath.suffix == ".cfg":
iniconfig = _parse_ini_config(filepath)
if "tool:pytest" in iniconfig.sections:
@ -60,7 +65,7 @@ def load_config_dict_from_file(
fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False)
# '.toml' files are considered if they contain a [tool.pytest.ini_options] table.
elif filepath.ext == ".toml":
elif filepath.suffix == ".toml":
import toml
config = toml.load(str(filepath))
@ -79,9 +84,9 @@ def load_config_dict_from_file(
def locate_config(
args: Iterable[Union[str, py.path.local]]
args: Iterable[Path],
) -> Tuple[
Optional[py.path.local], Optional[py.path.local], Dict[str, Union[str, List[str]]],
Optional[Path], Optional[Path], Dict[str, Union[str, List[str]]],
]:
"""Search in the list of arguments for a valid ini-file for pytest,
and return a tuple of (rootdir, inifile, cfg-dict)."""
@ -93,62 +98,77 @@ def locate_config(
]
args = [x for x in args if not str(x).startswith("-")]
if not args:
args = [py.path.local()]
args = [Path.cwd()]
for arg in args:
arg = py.path.local(arg)
for base in arg.parts(reverse=True):
argpath = absolutepath(arg)
for base in itertools.chain((argpath,), reversed(argpath.parents)):
for config_name in config_names:
p = base.join(config_name)
if p.isfile():
p = base / config_name
if p.is_file():
ini_config = load_config_dict_from_file(p)
if ini_config is not None:
return base, p, ini_config
return None, None, {}
def get_common_ancestor(paths: Iterable[py.path.local]) -> py.path.local:
common_ancestor = None # type: Optional[py.path.local]
def get_common_ancestor(paths: Iterable[Path]) -> Path:
common_ancestor = None # type: Optional[Path]
for path in paths:
if not path.exists():
continue
if common_ancestor is None:
common_ancestor = path
else:
if path.relto(common_ancestor) or path == common_ancestor:
if common_ancestor in path.parents or path == common_ancestor:
continue
elif common_ancestor.relto(path):
elif path in common_ancestor.parents:
common_ancestor = path
else:
shared = path.common(common_ancestor)
shared = commonpath(path, common_ancestor)
if shared is not None:
common_ancestor = shared
if common_ancestor is None:
common_ancestor = py.path.local()
elif common_ancestor.isfile():
common_ancestor = common_ancestor.dirpath()
common_ancestor = Path.cwd()
elif common_ancestor.is_file():
common_ancestor = common_ancestor.parent
return common_ancestor
def get_dirs_from_args(args: Iterable[str]) -> List[py.path.local]:
def get_dirs_from_args(args: Iterable[str]) -> List[Path]:
def is_option(x: str) -> bool:
return x.startswith("-")
def get_file_part_from_node_id(x: str) -> str:
return x.split("::")[0]
def get_dir_from_path(path: py.path.local) -> py.path.local:
if path.isdir():
def get_dir_from_path(path: Path) -> Path:
if path.is_dir():
return path
return py.path.local(path.dirname)
return path.parent
if sys.version_info < (3, 8):
def safe_exists(path: Path) -> bool:
# On Python<3.8, this can throw on paths that contain characters
# unrepresentable at the OS level.
try:
return path.exists()
except OSError:
return False
else:
def safe_exists(path: Path) -> bool:
return path.exists()
# These look like paths but may not exist
possible_paths = (
py.path.local(get_file_part_from_node_id(arg))
absolutepath(get_file_part_from_node_id(arg))
for arg in args
if not is_option(arg)
)
return [get_dir_from_path(path) for path in possible_paths if path.exists()]
return [get_dir_from_path(path) for path in possible_paths if safe_exists(path)]
CFG_PYTEST_SECTION = "[pytest] section in {filename} files is no longer supported, change to [tool:pytest] instead."
@ -156,15 +176,15 @@ CFG_PYTEST_SECTION = "[pytest] section in {filename} files is no longer supporte
def determine_setup(
inifile: Optional[str],
args: List[str],
args: Sequence[str],
rootdir_cmd_arg: Optional[str] = None,
config: Optional["Config"] = None,
) -> Tuple[py.path.local, Optional[py.path.local], Dict[str, Union[str, List[str]]]]:
) -> Tuple[Path, Optional[Path], Dict[str, Union[str, List[str]]]]:
rootdir = None
dirs = get_dirs_from_args(args)
if inifile:
inipath_ = py.path.local(inifile)
inipath = inipath_ # type: Optional[py.path.local]
inipath_ = absolutepath(inifile)
inipath = inipath_ # type: Optional[Path]
inicfg = load_config_dict_from_file(inipath_) or {}
if rootdir_cmd_arg is None:
rootdir = get_common_ancestor(dirs)
@ -172,8 +192,10 @@ def determine_setup(
ancestor = get_common_ancestor(dirs)
rootdir, inipath, inicfg = locate_config([ancestor])
if rootdir is None and rootdir_cmd_arg is None:
for possible_rootdir in ancestor.parts(reverse=True):
if possible_rootdir.join("setup.py").exists():
for possible_rootdir in itertools.chain(
(ancestor,), reversed(ancestor.parents)
):
if (possible_rootdir / "setup.py").is_file():
rootdir = possible_rootdir
break
else:
@ -181,16 +203,16 @@ def determine_setup(
rootdir, inipath, inicfg = locate_config(dirs)
if rootdir is None:
if config is not None:
cwd = config.invocation_dir
cwd = config.invocation_params.dir
else:
cwd = py.path.local()
cwd = Path.cwd()
rootdir = get_common_ancestor([cwd, ancestor])
is_fs_root = os.path.splitdrive(str(rootdir))[1] == "/"
if is_fs_root:
rootdir = ancestor
if rootdir_cmd_arg:
rootdir = py.path.local(os.path.expandvars(rootdir_cmd_arg))
if not rootdir.isdir():
rootdir = absolutepath(os.path.expandvars(rootdir_cmd_arg))
if not rootdir.is_dir():
raise UsageError(
"Directory '{}' not found. Check your '--rootdir' option.".format(
rootdir

View File

@ -21,17 +21,27 @@ from _pytest.config.exceptions import UsageError
from _pytest.config.findpaths import determine_setup
from _pytest.config.findpaths import get_common_ancestor
from _pytest.config.findpaths import locate_config
from _pytest.monkeypatch import MonkeyPatch
from _pytest.pathlib import Path
from _pytest.pytester import Testdir
class TestParseIni:
@pytest.mark.parametrize(
"section, filename", [("pytest", "pytest.ini"), ("tool:pytest", "setup.cfg")]
)
def test_getcfg_and_config(self, testdir, tmpdir, section, filename):
sub = tmpdir.mkdir("sub")
sub.chdir()
tmpdir.join(filename).write(
def test_getcfg_and_config(
self,
testdir: Testdir,
tmp_path: Path,
section: str,
filename: str,
monkeypatch: MonkeyPatch,
) -> None:
sub = tmp_path / "sub"
sub.mkdir()
monkeypatch.chdir(sub)
(tmp_path / filename).write_text(
textwrap.dedent(
"""\
[{section}]
@ -39,17 +49,14 @@ class TestParseIni:
""".format(
section=section
)
)
),
encoding="utf-8",
)
_, _, cfg = locate_config([sub])
assert cfg["name"] == "value"
config = testdir.parseconfigure(sub)
config = testdir.parseconfigure(str(sub))
assert config.inicfg["name"] == "value"
def test_getcfg_empty_path(self):
"""Correctly handle zero length arguments (a la pytest '')."""
locate_config([""])
def test_setupcfg_uses_toolpytest_with_pytest(self, testdir):
p1 = testdir.makepyfile("def test(): pass")
testdir.makefile(
@ -1168,16 +1175,17 @@ def test_collect_pytest_prefix_bug(pytestconfig):
class TestRootdir:
def test_simple_noini(self, tmpdir):
assert get_common_ancestor([tmpdir]) == tmpdir
a = tmpdir.mkdir("a")
assert get_common_ancestor([a, tmpdir]) == tmpdir
assert get_common_ancestor([tmpdir, a]) == tmpdir
with tmpdir.as_cwd():
assert get_common_ancestor([]) == tmpdir
no_path = tmpdir.join("does-not-exist")
assert get_common_ancestor([no_path]) == tmpdir
assert get_common_ancestor([no_path.join("a")]) == tmpdir
def test_simple_noini(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
assert get_common_ancestor([tmp_path]) == tmp_path
a = tmp_path / "a"
a.mkdir()
assert get_common_ancestor([a, tmp_path]) == tmp_path
assert get_common_ancestor([tmp_path, a]) == tmp_path
monkeypatch.chdir(tmp_path)
assert get_common_ancestor([]) == tmp_path
no_path = tmp_path / "does-not-exist"
assert get_common_ancestor([no_path]) == tmp_path
assert get_common_ancestor([no_path / "a"]) == tmp_path
@pytest.mark.parametrize(
"name, contents",
@ -1190,44 +1198,49 @@ class TestRootdir:
pytest.param("setup.cfg", "[tool:pytest]\nx=10", id="setup.cfg"),
],
)
def test_with_ini(self, tmpdir: py.path.local, name: str, contents: str) -> None:
inifile = tmpdir.join(name)
inifile.write(contents)
def test_with_ini(self, tmp_path: Path, name: str, contents: str) -> None:
inipath = tmp_path / name
inipath.write_text(contents, "utf-8")
a = tmpdir.mkdir("a")
b = a.mkdir("b")
for args in ([str(tmpdir)], [str(a)], [str(b)]):
rootdir, parsed_inifile, _ = determine_setup(None, args)
assert rootdir == tmpdir
assert parsed_inifile == inifile
rootdir, parsed_inifile, ini_config = determine_setup(None, [str(b), str(a)])
assert rootdir == tmpdir
assert parsed_inifile == inifile
a = tmp_path / "a"
a.mkdir()
b = a / "b"
b.mkdir()
for args in ([str(tmp_path)], [str(a)], [str(b)]):
rootpath, parsed_inipath, _ = determine_setup(None, args)
assert rootpath == tmp_path
assert parsed_inipath == inipath
rootpath, parsed_inipath, ini_config = determine_setup(None, [str(b), str(a)])
assert rootpath == tmp_path
assert parsed_inipath == inipath
assert ini_config == {"x": "10"}
@pytest.mark.parametrize("name", "setup.cfg tox.ini".split())
def test_pytestini_overrides_empty_other(self, tmpdir: py.path.local, name) -> None:
inifile = tmpdir.ensure("pytest.ini")
a = tmpdir.mkdir("a")
a.ensure(name)
rootdir, parsed_inifile, _ = determine_setup(None, [str(a)])
assert rootdir == tmpdir
assert parsed_inifile == inifile
@pytest.mark.parametrize("name", ["setup.cfg", "tox.ini"])
def test_pytestini_overrides_empty_other(self, tmp_path: Path, name: str) -> None:
inipath = tmp_path / "pytest.ini"
inipath.touch()
a = tmp_path / "a"
a.mkdir()
(a / name).touch()
rootpath, parsed_inipath, _ = determine_setup(None, [str(a)])
assert rootpath == tmp_path
assert parsed_inipath == inipath
def test_setuppy_fallback(self, tmpdir: py.path.local) -> None:
a = tmpdir.mkdir("a")
a.ensure("setup.cfg")
tmpdir.ensure("setup.py")
rootdir, inifile, inicfg = determine_setup(None, [str(a)])
assert rootdir == tmpdir
assert inifile is None
def test_setuppy_fallback(self, tmp_path: Path) -> None:
a = tmp_path / "a"
a.mkdir()
(a / "setup.cfg").touch()
(tmp_path / "setup.py").touch()
rootpath, inipath, inicfg = determine_setup(None, [str(a)])
assert rootpath == tmp_path
assert inipath is None
assert inicfg == {}
def test_nothing(self, tmpdir: py.path.local, monkeypatch) -> None:
monkeypatch.chdir(str(tmpdir))
rootdir, inifile, inicfg = determine_setup(None, [str(tmpdir)])
assert rootdir == tmpdir
assert inifile is None
def test_nothing(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
rootpath, inipath, inicfg = determine_setup(None, [str(tmp_path)])
assert rootpath == tmp_path
assert inipath is None
assert inicfg == {}
@pytest.mark.parametrize(
@ -1242,45 +1255,58 @@ class TestRootdir:
],
)
def test_with_specific_inifile(
self, tmpdir: py.path.local, name: str, contents: str
self, tmp_path: Path, name: str, contents: str
) -> None:
p = tmpdir.ensure(name)
p.write(contents)
rootdir, inifile, ini_config = determine_setup(str(p), [str(tmpdir)])
assert rootdir == tmpdir
assert inifile == p
p = tmp_path / name
p.touch()
p.write_text(contents, "utf-8")
rootpath, inipath, ini_config = determine_setup(str(p), [str(tmp_path)])
assert rootpath == tmp_path
assert inipath == p
assert ini_config == {"x": "10"}
def test_with_arg_outside_cwd_without_inifile(self, tmpdir, monkeypatch) -> None:
monkeypatch.chdir(str(tmpdir))
a = tmpdir.mkdir("a")
b = tmpdir.mkdir("b")
rootdir, inifile, _ = determine_setup(None, [str(a), str(b)])
assert rootdir == tmpdir
def test_with_arg_outside_cwd_without_inifile(
self, tmp_path: Path, monkeypatch: MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
a = tmp_path / "a"
a.mkdir()
b = tmp_path / "b"
b.mkdir()
rootpath, inifile, _ = determine_setup(None, [str(a), str(b)])
assert rootpath == tmp_path
assert inifile is None
def test_with_arg_outside_cwd_with_inifile(self, tmpdir) -> None:
a = tmpdir.mkdir("a")
b = tmpdir.mkdir("b")
inifile = a.ensure("pytest.ini")
rootdir, parsed_inifile, _ = determine_setup(None, [str(a), str(b)])
assert rootdir == a
assert inifile == parsed_inifile
def test_with_arg_outside_cwd_with_inifile(self, tmp_path: Path) -> None:
a = tmp_path / "a"
a.mkdir()
b = tmp_path / "b"
b.mkdir()
inipath = a / "pytest.ini"
inipath.touch()
rootpath, parsed_inipath, _ = determine_setup(None, [str(a), str(b)])
assert rootpath == a
assert inipath == parsed_inipath
@pytest.mark.parametrize("dirs", ([], ["does-not-exist"], ["a/does-not-exist"]))
def test_with_non_dir_arg(self, dirs, tmpdir) -> None:
with tmpdir.ensure(dir=True).as_cwd():
rootdir, inifile, _ = determine_setup(None, dirs)
assert rootdir == tmpdir
assert inifile is None
def test_with_non_dir_arg(
self, dirs: Sequence[str], tmp_path: Path, monkeypatch: MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
rootpath, inipath, _ = determine_setup(None, dirs)
assert rootpath == tmp_path
assert inipath is None
def test_with_existing_file_in_subdir(self, tmpdir) -> None:
a = tmpdir.mkdir("a")
a.ensure("exist")
with tmpdir.as_cwd():
rootdir, inifile, _ = determine_setup(None, ["a/exist"])
assert rootdir == tmpdir
assert inifile is None
def test_with_existing_file_in_subdir(
self, tmp_path: Path, monkeypatch: MonkeyPatch
) -> None:
a = tmp_path / "a"
a.mkdir()
(a / "exists").touch()
monkeypatch.chdir(tmp_path)
rootpath, inipath, _ = determine_setup(None, ["a/exist"])
assert rootpath == tmp_path
assert inipath is None
class TestOverrideIniArgs:

View File

@ -1,74 +1,74 @@
from textwrap import dedent
import py
import pytest
from _pytest.config.findpaths import get_common_ancestor
from _pytest.config.findpaths import load_config_dict_from_file
from _pytest.pathlib import Path
class TestLoadConfigDictFromFile:
def test_empty_pytest_ini(self, tmpdir):
def test_empty_pytest_ini(self, tmp_path: Path) -> None:
"""pytest.ini files are always considered for configuration, even if empty"""
fn = tmpdir.join("pytest.ini")
fn.write("")
fn = tmp_path / "pytest.ini"
fn.write_text("", encoding="utf-8")
assert load_config_dict_from_file(fn) == {}
def test_pytest_ini(self, tmpdir):
def test_pytest_ini(self, tmp_path: Path) -> None:
"""[pytest] section in pytest.ini files is read correctly"""
fn = tmpdir.join("pytest.ini")
fn.write("[pytest]\nx=1")
fn = tmp_path / "pytest.ini"
fn.write_text("[pytest]\nx=1", encoding="utf-8")
assert load_config_dict_from_file(fn) == {"x": "1"}
def test_custom_ini(self, tmpdir):
def test_custom_ini(self, tmp_path: Path) -> None:
"""[pytest] section in any .ini file is read correctly"""
fn = tmpdir.join("custom.ini")
fn.write("[pytest]\nx=1")
fn = tmp_path / "custom.ini"
fn.write_text("[pytest]\nx=1", encoding="utf-8")
assert load_config_dict_from_file(fn) == {"x": "1"}
def test_custom_ini_without_section(self, tmpdir):
def test_custom_ini_without_section(self, tmp_path: Path) -> None:
"""Custom .ini files without [pytest] section are not considered for configuration"""
fn = tmpdir.join("custom.ini")
fn.write("[custom]")
fn = tmp_path / "custom.ini"
fn.write_text("[custom]", encoding="utf-8")
assert load_config_dict_from_file(fn) is None
def test_custom_cfg_file(self, tmpdir):
def test_custom_cfg_file(self, tmp_path: Path) -> None:
"""Custom .cfg files without [tool:pytest] section are not considered for configuration"""
fn = tmpdir.join("custom.cfg")
fn.write("[custom]")
fn = tmp_path / "custom.cfg"
fn.write_text("[custom]", encoding="utf-8")
assert load_config_dict_from_file(fn) is None
def test_valid_cfg_file(self, tmpdir):
def test_valid_cfg_file(self, tmp_path: Path) -> None:
"""Custom .cfg files with [tool:pytest] section are read correctly"""
fn = tmpdir.join("custom.cfg")
fn.write("[tool:pytest]\nx=1")
fn = tmp_path / "custom.cfg"
fn.write_text("[tool:pytest]\nx=1", encoding="utf-8")
assert load_config_dict_from_file(fn) == {"x": "1"}
def test_unsupported_pytest_section_in_cfg_file(self, tmpdir):
def test_unsupported_pytest_section_in_cfg_file(self, tmp_path: Path) -> None:
""".cfg files with [pytest] section are no longer supported and should fail to alert users"""
fn = tmpdir.join("custom.cfg")
fn.write("[pytest]")
fn = tmp_path / "custom.cfg"
fn.write_text("[pytest]", encoding="utf-8")
with pytest.raises(pytest.fail.Exception):
load_config_dict_from_file(fn)
def test_invalid_toml_file(self, tmpdir):
def test_invalid_toml_file(self, tmp_path: Path) -> None:
""".toml files without [tool.pytest.ini_options] are not considered for configuration."""
fn = tmpdir.join("myconfig.toml")
fn.write(
fn = tmp_path / "myconfig.toml"
fn.write_text(
dedent(
"""
[build_system]
x = 1
"""
)
),
encoding="utf-8",
)
assert load_config_dict_from_file(fn) is None
def test_valid_toml_file(self, tmpdir):
def test_valid_toml_file(self, tmp_path: Path) -> None:
""".toml files with [tool.pytest.ini_options] are read correctly, including changing
data types to str/list for compatibility with other configuration options."""
fn = tmpdir.join("myconfig.toml")
fn.write(
fn = tmp_path / "myconfig.toml"
fn.write_text(
dedent(
"""
[tool.pytest.ini_options]
@ -77,7 +77,8 @@ class TestLoadConfigDictFromFile:
values = ["tests", "integration"]
name = "foo"
"""
)
),
encoding="utf-8",
)
assert load_config_dict_from_file(fn) == {
"x": "1",
@ -88,23 +89,22 @@ class TestLoadConfigDictFromFile:
class TestCommonAncestor:
def test_has_ancestor(self, tmpdir):
fn1 = tmpdir.join("foo/bar/test_1.py").ensure(file=1)
fn2 = tmpdir.join("foo/zaz/test_2.py").ensure(file=1)
assert get_common_ancestor([fn1, fn2]) == tmpdir.join("foo")
assert get_common_ancestor([py.path.local(fn1.dirname), fn2]) == tmpdir.join(
"foo"
)
assert get_common_ancestor(
[py.path.local(fn1.dirname), py.path.local(fn2.dirname)]
) == tmpdir.join("foo")
assert get_common_ancestor([fn1, py.path.local(fn2.dirname)]) == tmpdir.join(
"foo"
)
def test_has_ancestor(self, tmp_path: Path) -> None:
fn1 = tmp_path / "foo" / "bar" / "test_1.py"
fn1.parent.mkdir(parents=True)
fn1.touch()
fn2 = tmp_path / "foo" / "zaz" / "test_2.py"
fn2.parent.mkdir(parents=True)
fn2.touch()
assert get_common_ancestor([fn1, fn2]) == tmp_path / "foo"
assert get_common_ancestor([fn1.parent, fn2]) == tmp_path / "foo"
assert get_common_ancestor([fn1.parent, fn2.parent]) == tmp_path / "foo"
assert get_common_ancestor([fn1, fn2.parent]) == tmp_path / "foo"
def test_single_dir(self, tmpdir):
assert get_common_ancestor([tmpdir]) == tmpdir
def test_single_dir(self, tmp_path: Path) -> None:
assert get_common_ancestor([tmp_path]) == tmp_path
def test_single_file(self, tmpdir):
fn = tmpdir.join("foo.py").ensure(file=1)
assert get_common_ancestor([fn]) == tmpdir
def test_single_file(self, tmp_path: Path) -> None:
fn = tmp_path / "foo.py"
fn.touch()
assert get_common_ancestor([fn]) == tmp_path