checkout: use index checkout

Needed for https://github.com/iterative/dvc/pull/9424
This commit is contained in:
Ruslan Kuprieiev
2023-06-04 23:27:01 +03:00
parent f9104275f3
commit 78637713cf
8 changed files with 152 additions and 124 deletions
+99 -44
View File
@@ -1,16 +1,12 @@
import logging import logging
import os import os
from typing import TYPE_CHECKING, Dict, List, Set from typing import Dict, List
from dvc.exceptions import CheckoutError, CheckoutErrorSuggestGit, NoOutputOrStageError from dvc.exceptions import CheckoutError, CheckoutErrorSuggestGit, NoOutputOrStageError
from dvc.utils import relpath from dvc.utils import relpath
from . import locked from . import locked
if TYPE_CHECKING:
from . import Repo
from .stage import StageInfo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,37 +26,41 @@ def _remove_unused_links(repo):
return ret return ret
def get_all_files_numbers(pairs): def _build_out_changes(index, changes):
return sum(stage.get_all_files_number(filter_info) for stage, filter_info in pairs) from dvc_data.index.checkout import MODIFY
out_keys = []
for out in index.outs:
if not out.use_cache:
continue
def _collect_pairs( ws, key = out.index_key
self: "Repo", targets, with_deps: bool, recursive: bool if ws != "repo":
) -> Set["StageInfo"]: continue
from dvc.stage.exceptions import StageFileBadNameError, StageFileDoesNotExistError
pairs: Set["StageInfo"] = set() out_keys.append(key)
for target in targets:
try:
pairs.update(
self.stage.collect_granular(
target, with_deps=with_deps, recursive=recursive
)
)
except (
StageFileDoesNotExistError,
StageFileBadNameError,
NoOutputOrStageError,
) as exc:
if not target:
raise
raise CheckoutErrorSuggestGit(target) from exc
return pairs out_changes = {}
for objects in changes.values():
for change in objects:
for out_key in out_keys:
if (
len(out_key) > len(change.key)
or change.key[: len(out_key)] != out_key
):
continue
if change.key == out_key:
out_changes[out_key] = change.typ
elif not out_changes.get(out_key):
out_changes[out_key] = MODIFY
break
return out_changes
@locked @locked
def checkout( def checkout( # noqa: C901
self, self,
targets=None, targets=None,
with_deps=False, with_deps=False,
@@ -70,13 +70,18 @@ def checkout(
allow_missing=False, allow_missing=False,
**kwargs, **kwargs,
): ):
from dvc import prompt
from dvc.fs.callbacks import Callback from dvc.fs.callbacks import Callback
from dvc.repo.index import build_data_index
from dvc.stage.exceptions import StageFileBadNameError, StageFileDoesNotExistError
from dvc_data.hashfile.checkout import CheckoutError as IndexCheckoutError
from dvc_data.index.checkout import ADD, DELETE, MODIFY
from dvc_data.index.checkout import checkout as icheckout
stats: Dict[str, List[str]] = { stats: Dict[str, List[str]] = {
"added": [], "added": [],
"deleted": [], "deleted": [],
"modified": [], "modified": [],
"failed": [],
} }
if not targets: if not targets:
targets = [None] targets = [None]
@@ -85,28 +90,78 @@ def checkout(
if isinstance(targets, str): if isinstance(targets, str):
targets = [targets] targets = [targets]
pairs = _collect_pairs(self, targets, with_deps, recursive) def onerror(target, exc):
total = get_all_files_numbers(pairs) if target and isinstance(
exc,
(
StageFileDoesNotExistError,
StageFileBadNameError,
NoOutputOrStageError,
),
):
raise CheckoutErrorSuggestGit(target) from exc
raise # pylint: disable=misplaced-bare-raise
view = self.index.targets_view(
targets,
recursive=recursive,
with_deps=with_deps,
onerror=onerror,
)
with Callback.as_tqdm_callback(
unit="entry",
desc="Building data index",
) as cb:
old = build_data_index(
view, self.root_dir, self.fs, compute_hash=True, callback=cb
)
new = view.data["repo"]
for out in view.outs:
out.changed_cache()
with Callback.as_tqdm_callback( with Callback.as_tqdm_callback(
unit="file", unit="file",
desc="Checkout", desc="Checkout",
disable=total == 0,
) as cb: ) as cb:
cb.set_size(total) try:
for stage, filter_info in pairs: changes = icheckout(
result = stage.checkout( new,
force=force, self.root_dir,
progress_callback=cb, self.fs,
old=old,
callback=cb,
delete=True,
prompt=prompt.confirm,
update_meta=False,
relink=relink, relink=relink,
filter_info=filter_info, force=force,
allow_missing=allow_missing, allow_missing=allow_missing,
state=self.state,
**kwargs, **kwargs,
) )
for key, items in result.items(): except IndexCheckoutError as exc:
stats[key].extend(_fspath_dir(path) for path in items) raise CheckoutError(exc.paths, {}) from exc
if stats.get("failed"): out_changes = _build_out_changes(view, changes)
raise CheckoutError(stats["failed"], stats)
typ_map = {ADD: "added", DELETE: "deleted", MODIFY: "modified"}
for key, typ in out_changes.items():
out_path = self.fs.path.join(self.root_dir, *key)
self.state.save_link(out_path, self.fs)
stats[typ_map[typ]].append(_fspath_dir(out_path))
failed = []
for out in view.outs:
if not out.use_cache:
continue
if not out.hash_info:
failed.append(_fspath_dir(out.fs_path))
if not allow_missing and failed:
raise CheckoutError(failed, stats)
del stats["failed"]
return stats return stats
+2 -4
View File
@@ -73,10 +73,8 @@ def _diff(
): ):
if ( if (
change.typ == UNCHANGED change.typ == UNCHANGED
and change.old and (not change.old or not change.old.hash_info)
and change.new and (not change.new or not change.new.hash_info)
and not change.old.hash_info
and not change.new.hash_info
): ):
# NOTE: emulating previous behaviour # NOTE: emulating previous behaviour
continue continue
+1 -1
View File
@@ -110,7 +110,7 @@ def save_imports(
desc="Downloading imports from source", desc="Downloading imports from source",
unit="files", unit="files",
) as cb: ) as cb:
checkout(data_view, tmpdir, cache.fs, callback=cb, storage="data") checkout(data_view, tmpdir, cache.fs, callback=cb, storage="remote")
md5(data_view) md5(data_view)
save(data_view, odb=cache, hardlink=True) save(data_view, odb=cache, hardlink=True)
+41 -6
View File
@@ -19,6 +19,7 @@ from typing import (
from funcy.debug import format_time from funcy.debug import format_time
from dvc.fs import LocalFileSystem from dvc.fs import LocalFileSystem
from dvc.fs.callbacks import DEFAULT_CALLBACK
from dvc.utils.objects import cached_property from dvc.utils.objects import cached_property
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -26,6 +27,7 @@ if TYPE_CHECKING:
from pygtrie import Trie from pygtrie import Trie
from dvc.dependency import Dependency, ParamsDependency from dvc.dependency import Dependency, ParamsDependency
from dvc.fs.callbacks import Callback
from dvc.output import Output from dvc.output import Output
from dvc.repo import Repo from dvc.repo import Repo
from dvc.repo.stage import StageInfo from dvc.repo.stage import StageInfo
@@ -113,14 +115,18 @@ def collect_files(
def _load_data_from_outs(index, prefix, outs): def _load_data_from_outs(index, prefix, outs):
from dvc_data.index import DataIndexEntry from dvc_data.index import DataIndexEntry, Meta
parents = set()
for out in outs: for out in outs:
if not out.use_cache: if not out.use_cache:
continue continue
ws, key = out.index_key ws, key = out.index_key
for key_len in range(1, len(key)):
parents.add((ws, key[:key_len]))
entry = DataIndexEntry( entry = DataIndexEntry(
key=key, key=key,
meta=out.meta, meta=out.meta,
@@ -137,6 +143,11 @@ def _load_data_from_outs(index, prefix, outs):
# index.view() work correctly. # index.view() work correctly.
index[(*prefix, ws, *key)] = entry index[(*prefix, ws, *key)] = entry
for ws, key in parents:
index[(*prefix, ws, *key)] = DataIndexEntry(
key=key, meta=Meta(isdir=True), loaded=True
)
def _load_storage_from_out(storage_map, key, out): def _load_storage_from_out(storage_map, key, out):
from dvc.config import NoRemoteError from dvc.config import NoRemoteError
@@ -163,7 +174,7 @@ def _load_storage_from_out(storage_map, key, out):
if out.stage.is_import: if out.stage.is_import:
dep = out.stage.deps[0] dep = out.stage.deps[0]
storage_map.add_data(FileStorage(key, dep.fs, dep.fs_path)) storage_map.add_remote(FileStorage(key, dep.fs, dep.fs_path))
class Index: class Index:
@@ -534,10 +545,12 @@ class IndexView:
lambda: _DataPrefixes(set(), set()) lambda: _DataPrefixes(set(), set())
) )
for out, filter_info in self._filtered_outs: for out, filter_info in self._filtered_outs:
if not out.use_cache:
continue
workspace, key = out.index_key workspace, key = out.index_key
if filter_info and out.fs.path.isin(filter_info, out.fs_path): if filter_info and out.fs.path.isin(filter_info, out.fs_path):
key = key + out.fs.path.relparts(filter_info, out.fs_path) key = key + out.fs.path.relparts(filter_info, out.fs_path)
entry = self._index.data[workspace][key] entry = self._index.data[workspace].get(key)
if entry and entry.meta and entry.meta.isdir: if entry and entry.meta and entry.meta.isdir:
prefixes[workspace].recursive.add(key) prefixes[workspace].recursive.add(key)
prefixes[workspace].explicit.update(key[:i] for i in range(len(key), 0, -1)) prefixes[workspace].explicit.update(key[:i] for i in range(len(key), 0, -1))
@@ -550,6 +563,9 @@ class IndexView:
ret: Dict[str, Set["DataIndexKey"]] = defaultdict(set) ret: Dict[str, Set["DataIndexKey"]] = defaultdict(set)
for out, filter_info in self._filtered_outs: for out, filter_info in self._filtered_outs:
if not out.use_cache:
continue
workspace, key = out.index_key workspace, key = out.index_key
if filter_info and out.fs.path.isin(filter_info, out.fs_path): if filter_info and out.fs.path.isin(filter_info, out.fs_path):
key = key + out.fs.path.relparts(filter_info, out.fs_path) key = key + out.fs.path.relparts(filter_info, out.fs_path)
@@ -579,14 +595,15 @@ class IndexView:
return data return data
def build_data_index( def build_data_index( # noqa: C901
index: Union["Index", "IndexView"], index: Union["Index", "IndexView"],
path: str, path: str,
fs: "FileSystem", fs: "FileSystem",
workspace: str = "repo", workspace: str = "repo",
compute_hash: Optional[bool] = False, compute_hash: Optional[bool] = False,
callback: "Callback" = DEFAULT_CALLBACK,
) -> "DataIndex": ) -> "DataIndex":
from dvc_data.index import DataIndex, DataIndexEntry from dvc_data.index import DataIndex, DataIndexEntry, Meta
from dvc_data.index.build import build_entries, build_entry from dvc_data.index.build import build_entries, build_entry
from dvc_data.index.save import build_tree from dvc_data.index.save import build_tree
@@ -595,9 +612,16 @@ def build_data_index(
ignore = index.repo.dvcignore ignore = index.repo.dvcignore
data = DataIndex() data = DataIndex()
parents = set()
for key in index.data_keys.get(workspace, set()): for key in index.data_keys.get(workspace, set()):
out_path = fs.path.join(path, *key) out_path = fs.path.join(path, *key)
for key_len in range(1, len(key)):
parents.add(key[:key_len])
if not fs.exists(out_path):
continue
try: try:
out_entry = build_entry( out_entry = build_entry(
out_path, out_path,
@@ -606,10 +630,11 @@ def build_data_index(
state=index.repo.state, state=index.repo.state,
) )
except FileNotFoundError: except FileNotFoundError:
out_entry = DataIndexEntry() continue
out_entry.key = key out_entry.key = key
data.add(out_entry) data.add(out_entry)
callback.relative_update(1)
if not out_entry.meta or not out_entry.meta.isdir: if not out_entry.meta or not out_entry.meta.isdir:
continue continue
@@ -628,6 +653,7 @@ def build_data_index(
entry.key = key + entry.key entry.key = key + entry.key
data.add(entry) data.add(entry)
callback.relative_update(1)
if compute_hash: if compute_hash:
tree_meta, tree = build_tree(data, key) tree_meta, tree = build_tree(data, key)
@@ -635,5 +661,14 @@ def build_data_index(
out_entry.hash_info = tree.hash_info out_entry.hash_info = tree.hash_info
out_entry.loaded = True out_entry.loaded = True
data.add(out_entry) data.add(out_entry)
callback.relative_update(1)
for key in parents:
parent_path = fs.path.join(path, *key)
if not fs.exists(parent_path):
continue
direntry = DataIndexEntry(key=key, meta=Meta(isdir=True), loaded=True)
data.add(direntry)
callback.relative_update(1)
return data return data
-5
View File
@@ -717,11 +717,6 @@ class Stage(params.StageParams):
for out in self.outs for out in self.outs
) )
def get_all_files_number(self, filter_info=None) -> int:
return sum(
out.get_files_number(filter_info) for out in self.filter_outs(filter_info)
)
def get_used_objs( def get_used_objs(
self, *args, **kwargs self, *args, **kwargs
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]: ) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
+1 -1
View File
@@ -34,7 +34,7 @@ dependencies = [
"configobj>=5.0.6", "configobj>=5.0.6",
"distro>=1.3", "distro>=1.3",
"dpath<3,>=2.1.0", "dpath<3,>=2.1.0",
"dvc-data>=0.53.0,<0.54", "dvc-data>=0.54.3,<0.55",
"dvc-http>=2.29.0", "dvc-http>=2.29.0",
"dvc-render>=0.3.1,<1", "dvc-render>=0.3.1,<1",
"dvc-studio-client>=0.9.2,<1", "dvc-studio-client>=0.9.2,<1",
+4 -4
View File
@@ -336,11 +336,11 @@ def test_should_update_state_entry_for_file_after_add(mocker, dvc, tmp_dir):
os.rename("foo", "foo.back") os.rename("foo", "foo.back")
ret = main(["checkout"]) ret = main(["checkout"])
assert ret == 0 assert ret == 0
assert file_md5_counter.mock.call_count == 1 assert file_md5_counter.mock.call_count == 2
ret = main(["status"]) ret = main(["status"])
assert ret == 0 assert ret == 0
assert file_md5_counter.mock.call_count == 1 assert file_md5_counter.mock.call_count == 2
def test_should_update_state_entry_for_directory_after_add(mocker, dvc, tmp_dir): def test_should_update_state_entry_for_directory_after_add(mocker, dvc, tmp_dir):
@@ -362,11 +362,11 @@ def test_should_update_state_entry_for_directory_after_add(mocker, dvc, tmp_dir)
os.rename("data", "data.back") os.rename("data", "data.back")
ret = main(["checkout"]) ret = main(["checkout"])
assert ret == 0 assert ret == 0
assert file_md5_counter.mock.call_count == 5 assert file_md5_counter.mock.call_count == 6
ret = main(["status"]) ret = main(["status"])
assert ret == 0 assert ret == 0
assert file_md5_counter.mock.call_count == 6 assert file_md5_counter.mock.call_count == 7
def test_add_commit(tmp_dir, dvc): def test_add_commit(tmp_dir, dvc):
+4 -59
View File
@@ -8,12 +8,7 @@ import pytest
from dvc.cli import main from dvc.cli import main
from dvc.dvcfile import PROJECT_FILE, load_file from dvc.dvcfile import PROJECT_FILE, load_file
from dvc.exceptions import ( from dvc.exceptions import CheckoutError, CheckoutErrorSuggestGit, NoOutputOrStageError
CheckoutError,
CheckoutErrorSuggestGit,
ConfirmRemoveError,
NoOutputOrStageError,
)
from dvc.fs import LocalFileSystem, system from dvc.fs import LocalFileSystem, system
from dvc.stage import Stage from dvc.stage import Stage
from dvc.stage.exceptions import StageFileDoesNotExistError from dvc.stage.exceptions import StageFileDoesNotExistError
@@ -82,7 +77,7 @@ def test_checkout_corrupted_cache_file(tmp_dir, dvc):
fd.write("1") fd.write("1")
with pytest.raises(CheckoutError): with pytest.raises(CheckoutError):
dvc.checkout(force=True) dvc.checkout(force=True, relink=True)
assert not os.path.isfile("foo") assert not os.path.isfile("foo")
assert not os.path.isfile(cache) assert not os.path.isfile(cache)
@@ -114,7 +109,7 @@ def test_checkout_corrupted_cache_dir(tmp_dir, dvc):
fobj.write("1") fobj.write("1")
with pytest.raises(CheckoutError): with pytest.raises(CheckoutError):
dvc.checkout(force=True) dvc.checkout(force=True, relink=True)
assert not os.path.exists(cache) assert not os.path.exists(cache)
@@ -294,19 +289,6 @@ def test_checkout_directory(tmp_dir, dvc):
assert os.path.exists("data") assert os.path.exists("data")
def test_checkout_hook(mocker, tmp_dir, dvc):
"""Test that dvc checkout handles EOFError gracefully, which is what
it will experience when running in a git hook.
"""
tmp_dir.dvc_gen({"data": {"foo": "foo"}})
mocker.patch("sys.stdout.isatty", return_value=True)
mocker.patch("dvc.prompt.input", side_effect=EOFError)
(tmp_dir / "data").gen("test", "test")
with pytest.raises(ConfirmRemoveError):
dvc.checkout()
def test_checkout_suggest_git(tmp_dir, dvc, scm): def test_checkout_suggest_git(tmp_dir, dvc, scm):
with pytest.raises(CheckoutErrorSuggestGit) as e: with pytest.raises(CheckoutErrorSuggestGit) as e:
dvc.checkout(targets="gitbranch") dvc.checkout(targets="gitbranch")
@@ -636,27 +618,6 @@ def test_checkout_recursive(tmp_dir, dvc):
} }
def test_checkout_for_external_outputs(tmp_dir, dvc, workspace):
workspace.gen("foo", "foo")
file_path = workspace / "foo"
dvc.add("remote://workspace/foo")
odb = dvc.cloud.get_remote_odb("workspace")
odb.fs.remove(str(file_path))
assert not file_path.exists()
stats = dvc.checkout(force=True)
assert stats == {**empty_checkout, "added": ["remote://workspace/foo"]}
assert file_path.exists()
workspace.gen("foo", "foo\nfoo")
stats = dvc.checkout(force=True)
assert stats == {**empty_checkout, "modified": ["remote://workspace/foo"]}
assert file_path.read_text() == "foo"
def test_checkouts_with_different_addressing(tmp_dir, dvc, run_copy): def test_checkouts_with_different_addressing(tmp_dir, dvc, run_copy):
tmp_dir.gen({"foo": "foo", "lorem": "lorem"}) tmp_dir.gen({"foo": "foo", "lorem": "lorem"})
run_copy("foo", "bar", name="copy-foo-bar") run_copy("foo", "bar", name="copy-foo-bar")
@@ -725,22 +686,6 @@ def test_checkouts_for_pipeline_tracked_outs(tmp_dir, dvc, scm, run_copy):
assert set(dvc.checkout()["added"]) == {"bar", "ipsum"} assert set(dvc.checkout()["added"]) == {"bar", "ipsum"}
def test_checkout_external_modified_file(tmp_dir, dvc, scm, mocker, workspace):
# regression: happened when file in external output changed and checkout
# was attempted without force, dvc checks if it's present in its cache
# before asking user to remove it.
workspace.gen("foo", "foo")
dvc.add("remote://workspace/foo", external=True)
scm.add(["foo.dvc"])
scm.commit("add foo")
workspace.gen("foo", "foobar") # messing up the external outputs
mocker.patch("dvc.prompt.confirm", return_value=True)
dvc.checkout()
assert (workspace / "foo").read_text() == "foo"
def test_checkout_executable(tmp_dir, dvc): def test_checkout_executable(tmp_dir, dvc):
tmp_dir.dvc_gen("foo", "foo") tmp_dir.dvc_gen("foo", "foo")
@@ -817,7 +762,7 @@ def test_checkout_partial_unchanged(tmp_dir, dvc):
assert len(stats["modified"]) == 1 assert len(stats["modified"]) == 1
stats = dvc.checkout(str(data_dir / "empty_sub_dir")) stats = dvc.checkout(str(data_dir / "empty_sub_dir"))
assert not any(stats.values()) assert stats == {**empty_checkout, "modified": ["data" + os.sep]}
dvc.checkout(str(data_dir)) dvc.checkout(str(data_dir))