repofs: use / as root_marker

This makes dvcfs/repofs much easier to work with and allows it to support
isabs/abspath/etc correctly, as previously any full path (with root_marker "")
would be impossible to distinguish from a relative one.

This also detaches external workspaces from the main repo workspace in
DvcFileSystem, which was necessary to be able to distinguish dvcfs abspaths
from local external output paths. Note that currently we still have limited
support for those (e.g. get/api supports them, but import doesn't) and this
change keeps it that way.
This commit is contained in:
Ruslan Kuprieiev
2022-05-08 17:19:53 +03:00
parent 562e61235b
commit b14a972cd0
16 changed files with 115 additions and 61 deletions
+2 -1
View File
@@ -19,8 +19,9 @@ def get_url(path, repo=None, rev=None, remote=None):
directory in the remote storage. directory in the remote storage.
""" """
with Repo.open(repo, rev=rev, subrepos=True, uninitialized=True) as _repo: with Repo.open(repo, rev=rev, subrepos=True, uninitialized=True) as _repo:
fs_path = _repo.repo_fs.from_os_path(path)
with reraise(FileNotFoundError, PathMissingError(path, repo)): with reraise(FileNotFoundError, PathMissingError(path, repo)):
info = _repo.repo_fs.info(path) info = _repo.repo_fs.info(fs_path)
dvc_info = info.get("dvc_info") dvc_info = info.get("dvc_info")
if not dvc_info: if not dvc_info:
+4 -1
View File
@@ -83,7 +83,10 @@ class FileSystem:
def path(self): def path(self):
from .path import Path from .path import Path
return Path(self.sep) def _getcwd():
return self.fs.root_marker
return Path(self.sep, getcwd=_getcwd)
@classmethod @classmethod
def _strip_protocol(cls, path: str) -> str: def _strip_protocol(cls, path: str) -> str:
+24 -13
View File
@@ -1,5 +1,4 @@
import logging import logging
import os
import threading import threading
import typing import typing
@@ -22,31 +21,43 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
repo: DVC repo. repo: DVC repo.
""" """
def __init__(self, **kwargs): root_marker = "/"
def __init__(self, workspace=None, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.repo = kwargs["repo"] self.repo = kwargs["repo"]
self.workspace = workspace or "repo"
@cached_property
def path(self):
from .path import Path
def _getcwd():
return self.root_marker
return Path(self.sep, getcwd=_getcwd)
@property @property
def config(self): def config(self):
raise NotImplementedError raise NotImplementedError
def _get_key(self, path): def _get_key(self, path):
from dvc.fs.local import LocalFileSystem if self.workspace != "repo":
from . import get_cloud_fs
from . import get_cloud_fs cls, kwargs, fs_path = get_cloud_fs(None, url=path)
cls, kwargs, fs_path = get_cloud_fs(None, url=path)
if cls != LocalFileSystem or os.path.isabs(path):
fs = cls(**kwargs) fs = cls(**kwargs)
return (cls.scheme, *fs.path.parts(fs_path)) return (self.workspace, *fs.path.parts(fs_path))
fs_key = "repo" path = self.path.abspath(path)
key = path.split(self.sep) if path == self.root_marker:
if key == ["."] or key == [""]: return (self.workspace,)
key = self.path.relparts(path, self.root_marker)
if key == (".") or key == (""):
key = () key = ()
return (fs_key, *key) return (self.workspace, *key)
def _get_fs_path(self, path: "AnyPath", remote=None): def _get_fs_path(self, path: "AnyPath", remote=None):
from dvc.config import NoRemoteError from dvc.config import NoRemoteError
+6
View File
@@ -40,6 +40,12 @@ class GitFileSystem(FileSystem): # pylint:disable=abstract-method
} }
) )
@cached_property
def path(self):
from .path import Path
return Path(self.sep, getcwd=os.getcwd)
@wrap_prop(threading.Lock()) @wrap_prop(threading.Lock())
@cached_property @cached_property
def fs(self) -> "FsspecGitFileSystem": def fs(self) -> "FsspecGitFileSystem":
+5 -1
View File
@@ -105,7 +105,11 @@ class Path:
return self.isin_or_eq(left, right) or self.isin(right, left) return self.isin_or_eq(left, right) or self.isin(right, left)
def relpath(self, path, start): def relpath(self, path, start):
return self.flavour.relpath(path, start=start) if not start:
start = "."
return self.flavour.relpath(
self.abspath(path), start=self.abspath(start)
)
def relparts(self, path, base): def relparts(self, path, base):
return self.parts(self.relpath(path, base)) return self.parts(self.relpath(path, base))
+15 -12
View File
@@ -73,6 +73,8 @@ class _RepoFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
kwargs: Additional keyword arguments passed to the `DvcFileSystem()`. kwargs: Additional keyword arguments passed to the `DvcFileSystem()`.
""" """
root_marker = "/"
PARAM_REPO_URL = "repo_url" PARAM_REPO_URL = "repo_url"
PARAM_REPO_ROOT = "repo_root" PARAM_REPO_ROOT = "repo_root"
PARAM_REV = "rev" PARAM_REV = "rev"
@@ -103,7 +105,10 @@ class _RepoFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
else: else:
self.repo_factory = repo_factory self.repo_factory = repo_factory
self.path = Path(self.sep) def _getcwd():
return self.root_marker
self.path = Path(self.sep, getcwd=_getcwd)
self.repo = repo self.repo = repo
self.hash_jobs = repo.fs.hash_jobs self.hash_jobs = repo.fs.hash_jobs
self._traverse_subrepos = subrepos self._traverse_subrepos = subrepos
@@ -258,17 +263,7 @@ class _RepoFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
""" """
Returns a pair of fss based on repo the path falls in, using prefix. Returns a pair of fss based on repo the path falls in, using prefix.
""" """
from dvc.utils import as_posix parts = self.path.relparts(path, self.root_marker)
if os.path.isabs(path):
if self.repo.fs.path.isin_or_eq(path, self.repo.root_dir):
path = self.repo.fs.path.relpath(path, self.repo.root_dir)
else:
return None, None, self.repo.dvcfs, path
path = as_posix(path)
parts = self.path.parts(path)
if parts and parts[0] == os.curdir: if parts and parts[0] == os.curdir:
parts = parts[1:] parts = parts[1:]
@@ -461,3 +456,11 @@ class RepoFileSystem(FileSystem):
@property @property
def config(self): def config(self):
return self.fs.config return self.fs.config
def from_os_path(self, path):
from dvc.utils import as_posix
if os.path.isabs(path):
path = os.path.relpath(path, self.repo.root_dir)
return as_posix(path)
+12 -2
View File
@@ -491,12 +491,22 @@ class Repo:
@contextmanager @contextmanager
def open_by_relpath(self, path, remote=None, mode="r", encoding=None): def open_by_relpath(self, path, remote=None, mode="r", encoding=None):
"""Opens a specified resource as a file descriptor""" """Opens a specified resource as a file descriptor"""
from dvc.fs.dvc import DvcFileSystem
from dvc.fs.repo import RepoFileSystem from dvc.fs.repo import RepoFileSystem
fs = RepoFileSystem(repo=self, subrepos=True) if os.path.isabs(path):
fs = DvcFileSystem(repo=self, workspace="local")
fs_path = path
else:
fs = RepoFileSystem(repo=self, subrepos=True)
fs_path = fs.from_os_path(path)
try: try:
with fs.open( with fs.open(
path, mode=mode, encoding=encoding, remote=remote fs_path,
mode=mode,
encoding=encoding,
remote=remote,
) as fobj: ) as fobj:
yield fobj yield fobj
except FileNotFoundError as exc: except FileNotFoundError as exc:
+4 -4
View File
@@ -1,5 +1,4 @@
import logging import logging
import os
from typing import TYPE_CHECKING, Callable, Iterable, List, Tuple from typing import TYPE_CHECKING, Callable, Iterable, List, Tuple
from dvc.types import AnyPath from dvc.types import AnyPath
@@ -34,8 +33,8 @@ def _collect_paths(
from dvc.fs.repo import RepoFileSystem from dvc.fs.repo import RepoFileSystem
from dvc.utils import relpath from dvc.utils import relpath
fs_paths = [os.path.abspath(target) for target in targets]
fs = RepoFileSystem(repo=repo) fs = RepoFileSystem(repo=repo)
fs_paths = [fs.from_os_path(target) for target in targets]
target_paths: StrPaths = [] target_paths: StrPaths = []
for fs_path in fs_paths: for fs_path in fs_paths:
@@ -60,10 +59,11 @@ def _filter_duplicates(
fs_res_paths = fs_paths fs_res_paths = fs_paths
for out in outs: for out in outs:
if out.fs_path in fs_paths: fs_path = out.repo.repo_fs.from_os_path(out.fs_path)
if fs_path in fs_paths:
res_outs.append(out) res_outs.append(out)
# MUTATING THE SAME LIST!! # MUTATING THE SAME LIST!!
fs_res_paths.remove(out.fs_path) fs_res_paths.remove(fs_path)
return res_outs, fs_res_paths return res_outs, fs_res_paths
+2 -1
View File
@@ -186,8 +186,9 @@ def _dir_output_paths(fs, fs_path, obj, targets=None):
def _filter_missing(repo_fs, paths): def _filter_missing(repo_fs, paths):
for path in paths: for path in paths:
fs_path = repo_fs.from_os_path(path)
try: try:
info = repo_fs.info(path) info = repo_fs.info(fs_path)
dvc_info = info.get("dvc_info") dvc_info = info.get("dvc_info")
if ( if (
dvc_info dvc_info
+14 -2
View File
@@ -50,14 +50,26 @@ def get(url, path, out=None, rev=None, jobs=None):
with external_repo( with external_repo(
url=url, rev=rev, cache_dir=tmp_dir, cache_types=cache_types url=url, rev=rev, cache_dir=tmp_dir, cache_types=cache_types
) as repo: ) as repo:
fs = repo.repo_fs
if os.path.isabs(path):
from dvc.fs.dvc import DvcFileSystem
fs = DvcFileSystem(repo=repo, workspace="local")
fs_path = path
else:
fs = repo.repo_fs
fs_path = fs.from_os_path(path)
with FsspecCallback.as_tqdm_callback( with FsspecCallback.as_tqdm_callback(
total=-1, total=-1,
desc=f"Downloading {fs.path.name(path)}", desc=f"Downloading {fs.path.name(path)}",
unit="files", unit="files",
) as cb: ) as cb:
fs.get( fs.get(
path, os.path.abspath(out), batch_size=jobs, callback=cb fs_path,
os.path.abspath(out),
batch_size=jobs,
callback=cb,
) )
finally: finally:
remove(tmp_dir) remove(tmp_dir)
+3 -1
View File
@@ -45,8 +45,10 @@ def ls(url, path=None, rev=None, recursive=None, dvc_only=False):
def _ls(fs, path, recursive=None, dvc_only=False): def _ls(fs, path, recursive=None, dvc_only=False):
fs_path = fs.from_os_path(path)
try: try:
fs_path = fs.info(path)["name"] fs_path = fs.info(fs_path)["name"]
except FileNotFoundError: except FileNotFoundError:
return {} return {}
+7 -3
View File
@@ -24,11 +24,11 @@ def _to_fs_paths(metrics: List[Output]) -> StrPaths:
result = [] result = []
for out in metrics: for out in metrics:
if out.metric: if out.metric:
result.append(out.fs_path) result.append(out.repo.repo_fs.from_os_path(out.fs_path))
elif out.live: elif out.live:
fs_path = summary_fs_path(out) fs_path = summary_fs_path(out)
if fs_path: if fs_path:
result.append(fs_path) result.append(out.repo.repo_fs.from_os_path(fs_path))
return result return result
@@ -78,12 +78,16 @@ def _read_metric(path, fs, rev, **kwargs):
def _read_metrics(repo, metrics, rev, onerror=None): def _read_metrics(repo, metrics, rev, onerror=None):
fs = RepoFileSystem(repo=repo) fs = RepoFileSystem(repo=repo)
relpath = ""
if repo.root_dir != repo.fs.path.getcwd():
relpath = repo.fs.path.relpath(repo.root_dir, repo.fs.path.getcwd())
res = {} res = {}
for metric in metrics: for metric in metrics:
if not fs.isfile(metric): if not fs.isfile(metric):
continue continue
res[os.path.relpath(metric, os.getcwd())] = _read_metric( res[os.path.join(relpath, *fs.path.parts(metric))] = _read_metric(
metric, fs, rev, onerror=onerror metric, fs, rev, onerror=onerror
) )
+4 -1
View File
@@ -251,7 +251,10 @@ def _collect_plots(
recursive=recursive, recursive=recursive,
) )
result = {plot.fs_path: _plot_props(plot) for plot in plots} result = {
repo.repo_fs.from_os_path(plot.fs_path): _plot_props(plot)
for plot in plots
}
result.update({fs_path: {} for fs_path in fs_paths}) result.update({fs_path: {} for fs_path in fs_paths})
return result return result
+1
View File
@@ -15,6 +15,7 @@ from dvc.utils.fs import remove
[ [
("", ("repo",)), ("", ("repo",)),
(".", ("repo",)), (".", ("repo",)),
("/", ("repo",)),
("foo", ("repo", "foo")), ("foo", ("repo", "foo")),
("dir/foo", ("repo", "dir", "foo")), ("dir/foo", ("repo", "dir", "foo")),
], ],
+12 -12
View File
@@ -474,16 +474,16 @@ def test_repo_fs_no_subrepos(tmp_dir, dvc, scm):
dvc._reset() dvc._reset()
fs = RepoFileSystem(repo=dvc) fs = RepoFileSystem(repo=dvc)
expected = [ expected = [
".dvcignore", "/.dvcignore",
".gitignore", "/.gitignore",
"lorem", "/lorem",
"lorem.dvc", "/lorem.dvc",
"dir", "/dir",
"dir/repo.txt", "/dir/repo.txt",
] ]
actual = [] actual = []
for root, dirs, files in fs.walk("", dvcfiles=True): for root, dirs, files in fs.walk("/", dvcfiles=True):
for entry in dirs + files: for entry in dirs + files:
actual.append(posixpath.join(root, entry)) actual.append(posixpath.join(root, entry))
@@ -657,9 +657,9 @@ def test_walk_nested_subrepos(tmp_dir, dvc, scm, traverse_subrepos):
if traverse_subrepos or repo_dir == tmp_dir: if traverse_subrepos or repo_dir == tmp_dir:
repo_dir_path = ( repo_dir_path = (
repo_dir.relative_to(tmp_dir).as_posix() "/" + repo_dir.relative_to(tmp_dir).as_posix()
if repo_dir != tmp_dir if repo_dir != tmp_dir
else "" else "/"
) )
expected[repo_dir_path] = set( expected[repo_dir_path] = set(
scm_files.keys() | dvc_files.keys() | extras scm_files.keys() | dvc_files.keys() | extras
@@ -675,13 +675,13 @@ def test_walk_nested_subrepos(tmp_dir, dvc, scm, traverse_subrepos):
if traverse_subrepos: if traverse_subrepos:
# update subrepos # update subrepos
expected[""].update(["subrepo1", "subrepo2"]) expected["/"].update(["subrepo1", "subrepo2"])
expected["subrepo1"].add("subrepo3") expected["/subrepo1"].add("subrepo3")
actual = {} actual = {}
fs = RepoFileSystem(repo=dvc) fs = RepoFileSystem(repo=dvc)
for root, dirs, files in fs.walk( for root, dirs, files in fs.walk(
"", ignore_subrepos=not traverse_subrepos "/", ignore_subrepos=not traverse_subrepos
): ):
actual[root] = set(dirs + files) actual[root] = set(dirs + files)
assert expected == actual assert expected == actual
-7
View File
@@ -137,10 +137,3 @@ def test_info_on_subrepos(make_tmp_dir, tmp_dir, dvc, scm, repo_fs):
assert info["repo"].root_dir == str( assert info["repo"].root_dir == str(
subrepo subrepo
), f"repo root didn't match for {path}" ), f"repo root didn't match for {path}"
# supports external outputs on top-level DVC repo
external_dir = make_tmp_dir("external-output")
external_dir.gen("bar", "bar")
dvc.add(str(external_dir / "bar"), external=True)
info = repo_fs.info((external_dir / "bar").fs_path)
assert info["repo"].root_dir == str(tmp_dir)