mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-02 15:28:30 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13d1276a12 | ||
|
|
7da20a7c74 | ||
|
|
a7175501aa | ||
|
|
57899ee86d | ||
|
|
4b9a4114fe | ||
|
|
82e559a51a | ||
|
|
33c6a9b19b | ||
|
|
14cc748139 | ||
|
|
40abf34c8c | ||
|
|
9fef38e778 | ||
|
|
b44e1c3646 | ||
|
|
c9b3d9dbae | ||
|
|
c2bf0dc713 | ||
|
|
451bd3ebc8 | ||
|
|
41bc81910f | ||
|
|
0aec0e3249 | ||
|
|
8406e605ab | ||
|
|
1bb7f39e14 | ||
|
|
a285d2ba47 | ||
|
|
13c1314099 | ||
|
|
27b54198a4 | ||
|
|
a7f01b65a5 | ||
|
|
de97a49401 | ||
|
|
4c957bf4ab | ||
|
|
6dd435eefe | ||
|
|
fb96b13f13 | ||
|
|
6c646a7070 | ||
|
|
955aedee13 | ||
|
|
e99e6cd367 | ||
|
|
1f8278252a | ||
|
|
38cd7d01c2 | ||
|
|
63b710b7c0 | ||
|
|
d97a19e97d | ||
|
|
fd76e0991f | ||
|
|
3d604a8455 | ||
|
|
6c33fbb617 | ||
|
|
3be395b3a7 | ||
|
|
114a07e7a4 | ||
|
|
34697ef298 | ||
|
|
1eccfa9d33 | ||
|
|
6c14bf39dc | ||
|
|
7444696dcd | ||
|
|
76be53bc3e | ||
|
|
8d71c3861f | ||
|
|
15de826266 | ||
|
|
9634efeb84 | ||
|
|
e3a541aa00 | ||
|
|
9b4b6e5b31 | ||
|
|
b294bca383 | ||
|
|
4a843dbd61 | ||
|
|
1e43c303a7 | ||
|
|
985c0328c7 | ||
|
|
d5722e3edf | ||
|
|
67b42ebe4f | ||
|
|
1d7e754044 | ||
|
|
c8739008d1 | ||
|
|
e731e35e6c | ||
|
|
cd7c95a785 | ||
|
|
771b89ddf2 | ||
|
|
64587e8b6d | ||
|
|
5faf9b3a7d | ||
|
|
12d1e3e981 | ||
|
|
d95bd62f8e | ||
|
|
005bca1da6 | ||
|
|
3d84df930b | ||
|
|
66d0df2d12 | ||
|
|
d8b67f6f78 | ||
|
|
6711b647d9 | ||
|
|
1e81983d6d | ||
|
|
97809661bd | ||
|
|
b229588df3 | ||
|
|
cae117cfd4 | ||
|
|
21af7438db | ||
|
|
27c1722b90 | ||
|
|
6c8673b158 | ||
|
|
6275f5d0d7 | ||
|
|
93f36d9351 | ||
|
|
58f0911234 | ||
|
|
f45311e658 | ||
|
|
1271db6b59 | ||
|
|
beb610c3ab | ||
|
|
4557de420c | ||
|
|
481a411a35 | ||
|
|
52047214b7 | ||
|
|
71bae7e4af |
+1
-1
@@ -3,7 +3,7 @@ coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: 85
|
||||
target: 90
|
||||
threshold: 10%
|
||||
patch:
|
||||
default:
|
||||
|
||||
@@ -42,13 +42,14 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-18.04, windows-2019, macos-10.15]
|
||||
pyv: ["3.6", "3.7", "3.8", "3.9"]
|
||||
# macos 11.0 workers take too long to start up
|
||||
# include:
|
||||
# - os: "macos-11.0"
|
||||
# pyv: "3.9"
|
||||
include:
|
||||
- os: "macos-10.15"
|
||||
pyv: "3.10.0-rc.1"
|
||||
exclude:
|
||||
- os: macos-10.15
|
||||
pyv: "3.9"
|
||||
- os: macos-10.15
|
||||
pyv: "3.7"
|
||||
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
@@ -68,10 +69,14 @@ jobs:
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: install libgit2 on Python3.10 in macOS
|
||||
if: matrix.pyv == '3.10.0-rc.1' && matrix.os == 'macos-10.15'
|
||||
run: |
|
||||
brew install libgit2
|
||||
- name: install
|
||||
if: steps.cache.pip-cache-dir.cache-hit != 'true'
|
||||
run: |
|
||||
pip install wheel
|
||||
pip install --upgrade pip setuptools wheel
|
||||
pip install -e ".[all,tests]"
|
||||
- name: install temporary dependencies
|
||||
run: |
|
||||
@@ -100,12 +105,15 @@ jobs:
|
||||
GDRIVE_CREDENTIALS_DATA: ${{ secrets.GDRIVE_CREDENTIALS_DATA }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID}}
|
||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET}}
|
||||
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT}}
|
||||
run: >-
|
||||
python -m tests -n=4
|
||||
--cov-report=xml --cov-report=term
|
||||
--all ${{ env.extra_test_args }}
|
||||
- name: upload coverage report
|
||||
uses: codecov/codecov-action@v1.5.2
|
||||
uses: codecov/codecov-action@v2.0.2
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -6,7 +6,7 @@ repos:
|
||||
- id: black
|
||||
language_version: python3
|
||||
repo: https://github.com/ambv/black
|
||||
rev: 21.6b0
|
||||
rev: 21.7b0
|
||||
- hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
- id: isort
|
||||
language_version: python3
|
||||
repo: https://github.com/timothycrosley/isort
|
||||
rev: 5.9.1
|
||||
rev: 5.9.3
|
||||
- hooks:
|
||||
- id: flake8
|
||||
language_version: python3
|
||||
|
||||
+3
-3
@@ -29,9 +29,9 @@ learning projects. Key features:
|
||||
#. Helps manage experiments with Git tags/branches and **metrics** tracking.
|
||||
|
||||
**DVC** aims to replace spreadsheet and document sharing tools (such as Excel or Google Docs)
|
||||
which are being used frequently as both knowledge repositories and team ledgers.
|
||||
DVC also replaces both ad-hoc scripts to track, move, and deploy different model versions;
|
||||
as well as ad-hoc data file suffixes and prefixes.
|
||||
frequently used as both knowledge repositories and team ledgers.
|
||||
DVC also replaces both ad-hoc scripts to track, move, and deploy different model versions
|
||||
and ad-hoc data file suffixes and prefixes.
|
||||
|
||||
.. contents:: **Contents**
|
||||
:backlinks: none
|
||||
|
||||
+9
-4
@@ -2,6 +2,8 @@ import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from .env import DVC_NO_ANALYTICS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -32,7 +34,7 @@ def collect_and_send_report(args=None, return_code=None):
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, mode="w") as fobj:
|
||||
json.dump(report, fobj)
|
||||
daemon(["analytics", fobj.name])
|
||||
daemon(["analytics", fobj.name])
|
||||
|
||||
|
||||
def is_enabled():
|
||||
@@ -42,9 +44,12 @@ def is_enabled():
|
||||
if env2bool("DVC_TEST"):
|
||||
return False
|
||||
|
||||
enabled = to_bool(
|
||||
Config(validate=False).get("core", {}).get("analytics", "true")
|
||||
)
|
||||
enabled = not os.getenv(DVC_NO_ANALYTICS)
|
||||
if enabled:
|
||||
enabled = to_bool(
|
||||
Config(validate=False).get("core", {}).get("analytics", "true")
|
||||
)
|
||||
|
||||
logger.debug("Analytics is {}abled.".format("en" if enabled else "dis"))
|
||||
|
||||
return enabled
|
||||
|
||||
+41
-38
@@ -11,17 +11,17 @@ from dvc.exceptions import (
|
||||
)
|
||||
from dvc.ignore import DvcIgnoreFilter
|
||||
from dvc.objects import check, load
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
from dvc.objects.stage import stage
|
||||
from dvc.remote.slow_link_detection import ( # type: ignore[attr-defined]
|
||||
from dvc.objects.db.slow_link_detection import ( # type: ignore[attr-defined]
|
||||
slow_link_guard,
|
||||
)
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
from dvc.objects.stage import stage
|
||||
from dvc.types import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _changed(path_info, fs, obj, cache):
|
||||
def _changed(path_info, fs, obj, cache, state=None):
|
||||
logger.trace("checking if '%s'('%s') has changed.", path_info, obj)
|
||||
|
||||
try:
|
||||
@@ -32,11 +32,15 @@ def _changed(path_info, fs, obj, cache):
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
actual = stage(cache, path_info, fs, obj.hash_info.name).hash_info
|
||||
except FileNotFoundError:
|
||||
logger.debug("'%s' doesn't exist.", path_info)
|
||||
return True
|
||||
actual = state.get(path_info, fs) if state else None
|
||||
if actual is None:
|
||||
try:
|
||||
actual = stage(
|
||||
cache, path_info, fs, obj.hash_info.name, dry_run=True
|
||||
)[1].hash_info
|
||||
except FileNotFoundError:
|
||||
logger.debug("'%s' doesn't exist.", path_info)
|
||||
return True
|
||||
|
||||
if obj.hash_info != actual:
|
||||
logger.debug(
|
||||
@@ -59,7 +63,9 @@ def _remove(path_info, fs, cache, force=False):
|
||||
fs.remove(path_info)
|
||||
return
|
||||
|
||||
current = stage(cache, path_info, fs, fs.PARAM_CHECKSUM).hash_info
|
||||
current = stage(cache, path_info, fs, fs.PARAM_CHECKSUM, dry_run=True)[
|
||||
1
|
||||
].hash_info
|
||||
try:
|
||||
obj = load(cache, current)
|
||||
check(cache, obj)
|
||||
@@ -120,9 +126,11 @@ def _try_links(cache, from_info, to_info, link_types):
|
||||
|
||||
|
||||
def _link(cache, from_info, to_info):
|
||||
assert cache.fs.isfile(from_info)
|
||||
cache.makedirs(to_info.parent)
|
||||
_try_links(cache, from_info, to_info, cache.cache_types)
|
||||
try:
|
||||
_try_links(cache, from_info, to_info, cache.cache_types)
|
||||
except FileNotFoundError as exc:
|
||||
raise CheckoutError([to_info]) from exc
|
||||
|
||||
|
||||
def _cache_is_copy(cache, path_info):
|
||||
@@ -163,7 +171,7 @@ def _checkout_file(
|
||||
modified = False
|
||||
cache_info = cache.hash_to_path_info(obj.hash_info.value)
|
||||
if fs.exists(path_info):
|
||||
if not relink and _changed(path_info, fs, obj, cache):
|
||||
if not relink and _changed(path_info, fs, obj, cache, state=state):
|
||||
modified = True
|
||||
_remove(path_info, fs, cache, force=force)
|
||||
_link(cache, cache_info, path_info)
|
||||
@@ -222,19 +230,23 @@ def _checkout_dir(
|
||||
|
||||
logger.debug("Linking directory '%s'.", path_info)
|
||||
|
||||
failed = []
|
||||
for entry_key, entry_obj in obj:
|
||||
entry_modified = _checkout_file(
|
||||
path_info.joinpath(*entry_key),
|
||||
fs,
|
||||
entry_obj,
|
||||
cache,
|
||||
force,
|
||||
progress_callback,
|
||||
relink,
|
||||
state=None,
|
||||
)
|
||||
if entry_modified:
|
||||
modified = True
|
||||
try:
|
||||
entry_modified = _checkout_file(
|
||||
path_info.joinpath(*entry_key),
|
||||
fs,
|
||||
entry_obj,
|
||||
cache,
|
||||
force,
|
||||
progress_callback,
|
||||
relink,
|
||||
state=None,
|
||||
)
|
||||
if entry_modified:
|
||||
modified = True
|
||||
except CheckoutError as exc:
|
||||
failed.extend(exc.target_infos)
|
||||
|
||||
modified = (
|
||||
_remove_redundant_files(
|
||||
@@ -243,6 +255,9 @@ def _checkout_dir(
|
||||
or modified
|
||||
)
|
||||
|
||||
if failed:
|
||||
raise CheckoutError(failed)
|
||||
|
||||
if state:
|
||||
state.save(path_info, fs, obj.hash_info)
|
||||
|
||||
@@ -310,21 +325,9 @@ def checkout(
|
||||
_remove(path_info, fs, cache, force=force)
|
||||
failed = path_info
|
||||
|
||||
elif not relink and not _changed(path_info, fs, obj, cache):
|
||||
elif not relink and not _changed(path_info, fs, obj, cache, state=state):
|
||||
logger.trace("Data '%s' didn't change.", path_info) # type: ignore
|
||||
skip = True
|
||||
else:
|
||||
try:
|
||||
check(cache, obj)
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
if not quiet:
|
||||
logger.warning(
|
||||
"Cache '%s' not found. File '%s' won't be created.",
|
||||
obj.hash_info,
|
||||
path_info,
|
||||
)
|
||||
_remove(path_info, fs, cache, force=force)
|
||||
failed = path_info
|
||||
|
||||
if failed or skip:
|
||||
if progress_callback and obj:
|
||||
|
||||
@@ -33,17 +33,12 @@ class CmdBase(ABC):
|
||||
|
||||
def __init__(self, args):
|
||||
from dvc.repo import Repo
|
||||
from dvc.updater import Updater
|
||||
|
||||
os.chdir(args.cd)
|
||||
|
||||
self.repo = Repo(uninitialized=self.UNINITIALIZED)
|
||||
self.config = self.repo.config
|
||||
self.args = args
|
||||
if self.repo.tmp_dir:
|
||||
hardlink_lock = self.config["core"].get("hardlink_lock", False)
|
||||
updater = Updater(self.repo.tmp_dir, hardlink_lock=hardlink_lock)
|
||||
updater.check()
|
||||
|
||||
def do_run(self):
|
||||
with self.repo:
|
||||
|
||||
+6
-4
@@ -36,18 +36,20 @@ def _collect_targets(repo, target, outs):
|
||||
return [stage.addressing for stage, _ in pairs]
|
||||
|
||||
targets = []
|
||||
|
||||
outs_trie = repo.index.outs_trie
|
||||
for stage, info in pairs:
|
||||
if not info:
|
||||
targets.extend([str(out) for out in stage.outs])
|
||||
continue
|
||||
|
||||
for out in repo.outs_trie.itervalues(prefix=info.parts): # noqa: B301
|
||||
for out in outs_trie.itervalues(prefix=info.parts): # noqa: B301
|
||||
targets.extend(str(out))
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
def _transform(repo, outs):
|
||||
def _transform(index, outs):
|
||||
import networkx as nx
|
||||
|
||||
from dvc.stage import Stage
|
||||
@@ -55,7 +57,7 @@ def _transform(repo, outs):
|
||||
def _relabel(node) -> str:
|
||||
return node.addressing if isinstance(node, Stage) else str(node)
|
||||
|
||||
G = repo.outs_graph if outs else repo.graph
|
||||
G = index.outs_graph if outs else index.graph
|
||||
return nx.relabel_nodes(G, _relabel, copy=True)
|
||||
|
||||
|
||||
@@ -85,7 +87,7 @@ def _filter(G, targets, full):
|
||||
|
||||
def _build(repo, target=None, full=False, outs=False):
|
||||
targets = _collect_targets(repo, target, outs)
|
||||
G = _transform(repo, outs)
|
||||
G = _transform(repo.index, outs)
|
||||
return _filter(G, targets, full)
|
||||
|
||||
|
||||
|
||||
+68
-19
@@ -16,6 +16,7 @@ from dvc.command.repro import add_arguments as add_repro_arguments
|
||||
from dvc.exceptions import DvcException, InvalidArgumentError
|
||||
from dvc.ui import ui
|
||||
from dvc.utils.flatten import flatten
|
||||
from dvc.utils.serialize import encode_exception
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rich.text import Text
|
||||
@@ -28,6 +29,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SHOW_MAX_WIDTH = 1024
|
||||
FILL_VALUE = "-"
|
||||
FILL_VALUE_ERRORED = "!"
|
||||
|
||||
|
||||
def _filter_name(names, label, filter_strs):
|
||||
@@ -90,6 +92,7 @@ def _filter_names(
|
||||
|
||||
def _update_names(names, items):
|
||||
for name, item in items:
|
||||
item = item.get("data", {})
|
||||
if isinstance(item, dict):
|
||||
item = flatten(item)
|
||||
names[name].update({key: None for key in item})
|
||||
@@ -100,7 +103,8 @@ def _collect_names(all_experiments, **kwargs):
|
||||
param_names = defaultdict(dict)
|
||||
|
||||
for _, experiments in all_experiments.items():
|
||||
for exp in experiments.values():
|
||||
for exp_data in experiments.values():
|
||||
exp = exp_data.get("data", {})
|
||||
_update_names(metric_names, exp.get("metrics", {}).items())
|
||||
_update_names(param_names, exp.get("params", {}).items())
|
||||
metric_names = _filter_names(
|
||||
@@ -150,8 +154,15 @@ def _collect_rows(
|
||||
)
|
||||
|
||||
new_checkpoint = True
|
||||
for i, (rev, exp) in enumerate(experiments.items()):
|
||||
queued = str(exp.get("queued") or "")
|
||||
for i, (rev, results) in enumerate(experiments.items()):
|
||||
exp = results.get("data", {})
|
||||
if exp.get("running"):
|
||||
state = "Running"
|
||||
elif exp.get("queued"):
|
||||
state = "Queued"
|
||||
else:
|
||||
state = FILL_VALUE
|
||||
executor = exp.get("executor", FILL_VALUE)
|
||||
is_baseline = rev == "baseline"
|
||||
|
||||
if is_baseline:
|
||||
@@ -163,7 +174,7 @@ def _collect_rows(
|
||||
tip = exp.get("checkpoint_tip")
|
||||
|
||||
parent_rev = exp.get("checkpoint_parent", "")
|
||||
parent_exp = experiments.get(parent_rev, {})
|
||||
parent_exp = experiments.get(parent_rev, {}).get("data", {})
|
||||
parent_tip = parent_exp.get("checkpoint_tip")
|
||||
|
||||
parent = ""
|
||||
@@ -190,15 +201,27 @@ def _collect_rows(
|
||||
row = [
|
||||
exp_name,
|
||||
name_rev,
|
||||
queued,
|
||||
typ,
|
||||
_format_time(exp.get("timestamp")),
|
||||
parent,
|
||||
state,
|
||||
executor,
|
||||
]
|
||||
fill_value = FILL_VALUE_ERRORED if results.get("error") else FILL_VALUE
|
||||
_extend_row(
|
||||
row, metric_names, exp.get("metrics", {}).items(), precision
|
||||
row,
|
||||
metric_names,
|
||||
exp.get("metrics", {}).items(),
|
||||
precision,
|
||||
fill_value=fill_value,
|
||||
)
|
||||
_extend_row(
|
||||
row,
|
||||
param_names,
|
||||
exp.get("params", {}).items(),
|
||||
precision,
|
||||
fill_value=fill_value,
|
||||
)
|
||||
_extend_row(row, param_names, exp.get("params", {}).items(), precision)
|
||||
|
||||
yield row
|
||||
|
||||
@@ -261,19 +284,23 @@ def _format_time(timestamp):
|
||||
return timestamp.strftime(fmt)
|
||||
|
||||
|
||||
def _extend_row(row, names, items, precision):
|
||||
def _extend_row(row, names, items, precision, fill_value=FILL_VALUE):
|
||||
from rich.text import Text
|
||||
|
||||
from dvc.compare import _format_field, with_value
|
||||
|
||||
if not items:
|
||||
row.extend(FILL_VALUE for keys in names.values() for _ in keys)
|
||||
row.extend(fill_value for keys in names.values() for _ in keys)
|
||||
return
|
||||
|
||||
for fname, item in items:
|
||||
for fname, data in items:
|
||||
item = data.get("data", {})
|
||||
item = flatten(item) if isinstance(item, dict) else {fname: item}
|
||||
for name in names[fname]:
|
||||
value = with_value(item.get(name), FILL_VALUE)
|
||||
value = with_value(
|
||||
item.get(name),
|
||||
FILL_VALUE_ERRORED if data.get("error", None) else fill_value,
|
||||
)
|
||||
# wrap field data in rich.Text, otherwise rich may
|
||||
# interpret unescaped braces from list/dict types as rich
|
||||
# markup tags
|
||||
@@ -305,7 +332,15 @@ def experiments_table(
|
||||
|
||||
from dvc.compare import TabularData
|
||||
|
||||
headers = ["Experiment", "rev", "queued", "typ", "Created", "parent"]
|
||||
headers = [
|
||||
"Experiment",
|
||||
"rev",
|
||||
"typ",
|
||||
"Created",
|
||||
"parent",
|
||||
"State",
|
||||
"Executor",
|
||||
]
|
||||
td = TabularData(
|
||||
lconcat(headers, metric_headers, param_headers), fill_value=FILL_VALUE
|
||||
)
|
||||
@@ -341,8 +376,7 @@ def prepare_exp_id(kwargs) -> "Text":
|
||||
text.append(suff)
|
||||
|
||||
tree = experiment_types[typ]
|
||||
queued = "*" if kwargs.get("queued") else ""
|
||||
pref = (f"{tree} " if tree else "") + queued
|
||||
pref = f"{tree} " if tree else ""
|
||||
return Text(pref) + text
|
||||
|
||||
|
||||
@@ -382,9 +416,13 @@ def show_experiments(
|
||||
if no_timestamp:
|
||||
td.drop("Created")
|
||||
|
||||
for col in ("State", "Executor"):
|
||||
if td.is_empty(col):
|
||||
td.drop(col)
|
||||
|
||||
row_styles = lmap(baseline_styler, td.column("typ"))
|
||||
|
||||
merge_headers = ["Experiment", "rev", "queued", "typ", "parent"]
|
||||
merge_headers = ["Experiment", "rev", "typ", "parent"]
|
||||
td.column("Experiment")[:] = map(prepare_exp_id, td.as_dict(merge_headers))
|
||||
td.drop(*merge_headers[1:])
|
||||
|
||||
@@ -392,6 +430,8 @@ def show_experiments(
|
||||
styles = {
|
||||
"Experiment": {"no_wrap": True, "header_style": "black on grey93"},
|
||||
"Created": {"header_style": "black on grey93"},
|
||||
"State": {"header_style": "black on grey93"},
|
||||
"Executor": {"header_style": "black on grey93"},
|
||||
}
|
||||
header_bg_colors = {"metrics": "cornsilk1", "params": "light_cyan1"}
|
||||
styles.update(
|
||||
@@ -430,7 +470,7 @@ def _normalize_headers(names):
|
||||
def _format_json(item):
|
||||
if isinstance(item, (date, datetime)):
|
||||
return item.isoformat()
|
||||
raise TypeError
|
||||
return encode_exception(item)
|
||||
|
||||
|
||||
class CmdExperimentsShow(CmdBase):
|
||||
@@ -589,7 +629,7 @@ class CmdExperimentsGC(CmdRepro):
|
||||
msg += " of the current repo."
|
||||
if self.args.queued:
|
||||
msg += " Run queued experiments will be preserved."
|
||||
if self.args.queued:
|
||||
else:
|
||||
msg += " Run queued experiments will be removed."
|
||||
|
||||
logger.warning(msg)
|
||||
@@ -707,7 +747,9 @@ class CmdExperimentsRemove(CmdBase):
|
||||
def run(self):
|
||||
|
||||
self.repo.experiments.remove(
|
||||
exp_names=self.args.experiment, queue=self.args.queue
|
||||
exp_names=self.args.experiment,
|
||||
queue=self.args.queue,
|
||||
clear_all=self.args.all,
|
||||
)
|
||||
|
||||
return 0
|
||||
@@ -1197,9 +1239,16 @@ def add_parser(subparsers, parent_parser):
|
||||
help=EXPERIMENTS_REMOVE_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
experiments_remove_parser.add_argument(
|
||||
remove_group = experiments_remove_parser.add_mutually_exclusive_group()
|
||||
remove_group.add_argument(
|
||||
"--queue", action="store_true", help="Remove all queued experiments."
|
||||
)
|
||||
remove_group.add_argument(
|
||||
"-A",
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Remove all committed experiments.",
|
||||
)
|
||||
experiments_remove_parser.add_argument(
|
||||
"experiment",
|
||||
nargs="*",
|
||||
|
||||
+6
-5
@@ -12,12 +12,13 @@ class CmdLive(CmdBase):
|
||||
def _run(self, target, revs=None):
|
||||
metrics, plots = self.repo.live.show(target=target, revs=revs)
|
||||
|
||||
html_path = self.args.target + ".html"
|
||||
self.repo.plots.write_html(html_path, plots, metrics)
|
||||
if plots:
|
||||
html_path = self.args.target + ".html"
|
||||
self.repo.plots.write_html(html_path, plots, metrics)
|
||||
|
||||
ui.write("\nfile://", os.path.abspath(html_path), sep="")
|
||||
|
||||
return 0
|
||||
ui.write("\nfile://", os.path.abspath(html_path), sep="")
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
class CmdLiveShow(CmdLive):
|
||||
|
||||
@@ -3,8 +3,9 @@ import logging
|
||||
|
||||
from dvc.command import completion
|
||||
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
|
||||
from dvc.exceptions import BadMetricError, DvcException
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.ui import ui
|
||||
from dvc.utils.serialize import encode_exception
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,17 +34,10 @@ class CmdMetricsShow(CmdMetricsBase):
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
ui.write(json.dumps(metrics))
|
||||
ui.write(json.dumps(metrics, default=encode_exception))
|
||||
else:
|
||||
from dvc.compare import show_metrics
|
||||
|
||||
# When `metrics` contains a `None` key, it means that some files
|
||||
# specified as `targets` in `repo.metrics.show` didn't contain any
|
||||
# metrics.
|
||||
missing = metrics.pop(None, None)
|
||||
if missing:
|
||||
raise BadMetricError(missing)
|
||||
|
||||
show_metrics(
|
||||
metrics,
|
||||
markdown=self.args.show_md,
|
||||
|
||||
+23
-17
@@ -42,29 +42,35 @@ class CmdPlots(CmdBase):
|
||||
ui.write(plots[target])
|
||||
return 0
|
||||
|
||||
rel: str = self.args.out or "plots.html"
|
||||
path = (Path.cwd() / rel).resolve()
|
||||
self.repo.plots.write_html(
|
||||
path, plots=plots, html_template_path=self.args.html_template
|
||||
)
|
||||
|
||||
except DvcException:
|
||||
logger.exception("")
|
||||
return 1
|
||||
|
||||
assert path.is_absolute() # as_uri throws ValueError if not absolute
|
||||
url = path.as_uri()
|
||||
ui.write(url)
|
||||
if self.args.open:
|
||||
import webbrowser
|
||||
if plots:
|
||||
rel: str = self.args.out or "plots.html"
|
||||
path: Path = (Path.cwd() / rel).resolve()
|
||||
self.repo.plots.write_html(
|
||||
path, plots=plots, html_template_path=self.args.html_template
|
||||
)
|
||||
|
||||
opened = webbrowser.open(rel)
|
||||
if not opened:
|
||||
ui.error_write(
|
||||
"Failed to open. Please try opening it manually."
|
||||
)
|
||||
return 1
|
||||
assert (
|
||||
path.is_absolute()
|
||||
) # as_uri throws ValueError if not absolute
|
||||
url = path.as_uri()
|
||||
ui.write(url)
|
||||
if self.args.open:
|
||||
import webbrowser
|
||||
|
||||
opened = webbrowser.open(rel)
|
||||
if not opened:
|
||||
ui.error_write(
|
||||
"Failed to open. Please try opening it manually."
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
ui.error_write(
|
||||
"No plots were loaded, visualization file will not be created."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ def prepare_stages_data(
|
||||
class CmdStageList(CmdBase):
|
||||
def _get_stages(self) -> Iterable["Stage"]:
|
||||
if self.args.all:
|
||||
stages: List["Stage"] = self.repo.stages # type: ignore
|
||||
stages: List["Stage"] = self.repo.index.stages # type: ignore
|
||||
logger.trace( # type: ignore[attr-defined]
|
||||
"%d no. of stages found", len(stages)
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ class CmdDataStatus(CmdDataBase):
|
||||
|
||||
for key, value in status.items():
|
||||
if isinstance(value, str):
|
||||
ui.write("{}{}{}".format(ind, self._normalize(value), key))
|
||||
ui.write(f"{ind}{self._normalize(value)}{key}")
|
||||
elif value:
|
||||
ui.write(f"{ind}{key}:")
|
||||
self._show(value, indent + 1)
|
||||
@@ -80,7 +80,7 @@ class CmdDataStatus(CmdDataBase):
|
||||
return 0
|
||||
|
||||
# additional hints for the user
|
||||
if not self.repo.stages:
|
||||
if not self.repo.index.stages:
|
||||
ui.write(self.EMPTY_PROJECT_MSG)
|
||||
elif self.args.cloud or self.args.remote:
|
||||
remote = self.args.remote or self.repo.config["core"].get(
|
||||
|
||||
@@ -10,9 +10,12 @@ logger = logging.getLogger(__name__)
|
||||
class CmdVersion(CmdBaseNoRepo):
|
||||
def run(self):
|
||||
from dvc.info import get_dvc_info
|
||||
from dvc.updater import notify_updates
|
||||
|
||||
dvc_info = get_dvc_info()
|
||||
ui.write(dvc_info, force=True)
|
||||
|
||||
notify_updates()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+6
-1
@@ -134,6 +134,10 @@ class TabularData(MutableSequence[Sequence["CellT"]]):
|
||||
self.drop(*(set(self._keys) - set(col_names)))
|
||||
self._keys = list(col_names)
|
||||
|
||||
def is_empty(self, col_name: str) -> bool:
|
||||
col = self.column(col_name)
|
||||
return not any(item != self._fill_value for item in col)
|
||||
|
||||
def to_csv(self) -> str:
|
||||
import csv
|
||||
from io import StringIO
|
||||
@@ -277,8 +281,9 @@ def metrics_table(
|
||||
td = TabularData(["Revision", "Path"], fill_value="-")
|
||||
|
||||
for branch, val in metrics.items():
|
||||
for fname, metric in val.items():
|
||||
for fname, metric in val.get("data", {}).items():
|
||||
row_data: Dict[str, str] = {"Revision": branch, "Path": fname}
|
||||
metric = metric.get("data", {})
|
||||
flattened = (
|
||||
flatten(format_dict(metric))
|
||||
if isinstance(metric, dict)
|
||||
|
||||
+9
-1
@@ -20,7 +20,15 @@ class ConfigError(DvcException):
|
||||
super().__init__(f"config file error: {msg}")
|
||||
|
||||
|
||||
class NoRemoteError(ConfigError):
|
||||
class RemoteConfigError(ConfigError):
|
||||
pass
|
||||
|
||||
|
||||
class NoRemoteError(RemoteConfigError):
|
||||
pass
|
||||
|
||||
|
||||
class RemoteNotFoundError(RemoteConfigError):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -189,7 +189,11 @@ SCHEMA = {
|
||||
"tenant_id": str,
|
||||
"client_id": str,
|
||||
"client_secret": str,
|
||||
"allow_anonymous_login": bool,
|
||||
"allow_anonymous_login": Bool,
|
||||
"exclude_environment_credential": Bool,
|
||||
"exclude_visual_studio_code_credential": Bool,
|
||||
"exclude_shared_token_cache_credential": Bool,
|
||||
"exclude_managed_identity_credential": Bool,
|
||||
**REMOTE_COMMON,
|
||||
},
|
||||
"oss": {
|
||||
|
||||
+49
-33
@@ -3,9 +3,11 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Iterable, Optional
|
||||
|
||||
from dvc.objects.db import get_index
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.objects.file import HashFile
|
||||
from dvc.remote.base import Remote
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db.base import ObjectDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,18 +26,18 @@ class DataCloud:
|
||||
def __init__(self, repo):
|
||||
self.repo = repo
|
||||
|
||||
def get_remote(
|
||||
def get_remote_odb(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
command: str = "<command>",
|
||||
) -> "Remote":
|
||||
) -> "ObjectDB":
|
||||
from dvc.config import NoRemoteError
|
||||
|
||||
if not name:
|
||||
name = self.repo.config["core"].get("remote")
|
||||
|
||||
if name:
|
||||
return self._init_remote(name)
|
||||
return self._init_odb(name)
|
||||
|
||||
if bool(self.repo.config["remote"]):
|
||||
error_msg = (
|
||||
@@ -52,69 +54,79 @@ class DataCloud:
|
||||
|
||||
raise NoRemoteError(error_msg)
|
||||
|
||||
def _init_remote(self, name):
|
||||
from dvc.remote import get_remote
|
||||
def _init_odb(self, name):
|
||||
from dvc.fs import get_cloud_fs
|
||||
from dvc.objects.db import get_odb
|
||||
|
||||
return get_remote(self.repo, name=name)
|
||||
cls, config, path_info = get_cloud_fs(self.repo, name=name)
|
||||
config["tmp_dir"] = self.repo.tmp_dir
|
||||
return get_odb(cls(**config), path_info, **config)
|
||||
|
||||
def push(
|
||||
self,
|
||||
objs: Iterable["HashFile"],
|
||||
objs: Iterable["HashInfo"],
|
||||
jobs: Optional[int] = None,
|
||||
remote: Optional[str] = None,
|
||||
show_checksums: bool = False,
|
||||
odb: Optional["ObjectDB"] = None,
|
||||
):
|
||||
"""Push data items in a cloud-agnostic way.
|
||||
|
||||
Args:
|
||||
objs: objects to push to the cloud.
|
||||
jobs: number of jobs that can be running simultaneously.
|
||||
remote: optional remote to push to.
|
||||
remote: optional name of remote to push to.
|
||||
By default remote from core.remote config option is used.
|
||||
show_checksums: show checksums instead of file names in
|
||||
information messages.
|
||||
odb: optional ODB to push to. Overrides remote.
|
||||
"""
|
||||
remote_obj = self.get_remote(remote, "push")
|
||||
from dvc.objects.transfer import transfer
|
||||
|
||||
return remote_obj.push(
|
||||
if not odb:
|
||||
odb = self.get_remote_odb(remote, "push")
|
||||
return transfer(
|
||||
self.repo.odb.local,
|
||||
odb,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
dest_index=get_index(odb),
|
||||
cache_odb=self.repo.odb.local,
|
||||
)
|
||||
|
||||
def pull(
|
||||
self,
|
||||
objs: Iterable["HashFile"],
|
||||
objs: Iterable["HashInfo"],
|
||||
jobs: Optional[int] = None,
|
||||
remote: Optional[str] = None,
|
||||
show_checksums: bool = False,
|
||||
odb: Optional["ObjectDB"] = None,
|
||||
):
|
||||
"""Pull data items in a cloud-agnostic way.
|
||||
|
||||
Args:
|
||||
objs: objects to pull from the cloud.
|
||||
jobs: number of jobs that can be running simultaneously.
|
||||
remote: optional remote to pull from.
|
||||
remote: optional name of remote to pull from.
|
||||
By default remote from core.remote config option is used.
|
||||
show_checksums: show checksums instead of file names in
|
||||
information messages.
|
||||
odb: optional ODB to pull from. Overrides remote.
|
||||
"""
|
||||
remote_obj = self.get_remote(remote, "pull")
|
||||
from dvc.objects.transfer import transfer
|
||||
|
||||
return remote_obj.pull(
|
||||
if not odb:
|
||||
odb = self.get_remote_odb(remote, "pull")
|
||||
return transfer(
|
||||
odb,
|
||||
self.repo.odb.local,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
src_index=get_index(odb),
|
||||
cache_odb=self.repo.odb.local,
|
||||
verify=odb.verify,
|
||||
)
|
||||
|
||||
def status(
|
||||
self,
|
||||
objs: Iterable["HashFile"],
|
||||
objs: Iterable["HashInfo"],
|
||||
jobs: Optional[int] = None,
|
||||
remote: Optional[str] = None,
|
||||
show_checksums: bool = False,
|
||||
odb: Optional["ObjectDB"] = None,
|
||||
log_missing: bool = True,
|
||||
):
|
||||
"""Check status of data items in a cloud-agnostic way.
|
||||
@@ -125,20 +137,24 @@ class DataCloud:
|
||||
remote: optional remote to compare
|
||||
cache to. By default remote from core.remote config option
|
||||
is used.
|
||||
show_checksums: show checksums instead of file names in
|
||||
information messages.
|
||||
odb: optional ODB to check status from. Overrides remote.
|
||||
log_missing: log warning messages if file doesn't exist
|
||||
neither in cache, neither in cloud.
|
||||
"""
|
||||
remote_obj = self.get_remote(remote, "status")
|
||||
return remote_obj.status(
|
||||
from dvc.objects.status import compare_status
|
||||
|
||||
if not odb:
|
||||
odb = self.get_remote_odb(remote, "status")
|
||||
return compare_status(
|
||||
self.repo.odb.local,
|
||||
odb,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
log_missing=log_missing,
|
||||
dest_index=get_index(odb),
|
||||
cache_odb=self.repo.odb.local,
|
||||
)
|
||||
|
||||
def get_url_for(self, remote, checksum):
|
||||
remote_obj = self.get_remote(remote)
|
||||
return str(remote_obj.odb.hash_to_path_info(checksum))
|
||||
remote_odb = self.get_remote_odb(remote)
|
||||
return str(remote_odb.hash_to_path_info(checksum))
|
||||
|
||||
@@ -98,7 +98,7 @@ class ParamsDependency(Dependency):
|
||||
f"Unable to read parameters from '{self}'"
|
||||
) from exc
|
||||
|
||||
def read_params_d(self):
|
||||
def read_params_d(self, **kwargs):
|
||||
config = self._read()
|
||||
|
||||
ret = {}
|
||||
|
||||
+75
-54
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Set
|
||||
from copy import copy
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple
|
||||
|
||||
from voluptuous import Required
|
||||
|
||||
@@ -9,6 +10,7 @@ from dvc.path_info import PathInfo
|
||||
from .base import Dependency
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db.base import ObjectDB
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
@@ -40,7 +42,7 @@ class RepoDependency(Dependency):
|
||||
return False
|
||||
|
||||
def __str__(self):
|
||||
return "{} ({})".format(self.def_path, self.def_repo[self.PARAM_URL])
|
||||
return f"{self.def_path} ({self.def_repo[self.PARAM_URL]})"
|
||||
|
||||
def workspace_status(self):
|
||||
current = self.get_obj(locked=True).hash_info
|
||||
@@ -62,16 +64,11 @@ class RepoDependency(Dependency):
|
||||
|
||||
def download(self, to, jobs=None):
|
||||
from dvc.checkout import checkout
|
||||
from dvc.objects import save
|
||||
from dvc.objects.db.git import GitObjectDB
|
||||
from dvc.repo.fetch import fetch_from_odb
|
||||
|
||||
for odb, objs in self.get_used_objs().items():
|
||||
if not isinstance(odb, GitObjectDB):
|
||||
fetch_from_odb(self.repo, odb, objs, jobs=jobs)
|
||||
self.repo.cloud.pull(objs, jobs=jobs, odb=odb)
|
||||
|
||||
obj = self.get_obj()
|
||||
save(self.repo.odb.local, obj, jobs=jobs)
|
||||
checkout(
|
||||
to.path_info,
|
||||
to.fs,
|
||||
@@ -95,39 +92,47 @@ class RepoDependency(Dependency):
|
||||
|
||||
def get_used_objs(
|
||||
self, **kwargs
|
||||
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
|
||||
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
|
||||
used, _ = self._get_used_and_obj(**kwargs)
|
||||
return used
|
||||
|
||||
def _get_used_and_obj(
|
||||
self, obj_only=False, **kwargs
|
||||
) -> Tuple[Dict[Optional["ObjectDB"], Set["HashInfo"]], "HashFile"]:
|
||||
from dvc.config import NoRemoteError
|
||||
from dvc.exceptions import NoOutputOrStageError, PathMissingError
|
||||
from dvc.objects.db.git import GitObjectDB
|
||||
from dvc.objects.stage import stage
|
||||
from dvc.objects.tree import Tree
|
||||
|
||||
local_odb = self.repo.odb.local
|
||||
locked = kwargs.pop("locked", True)
|
||||
with self._make_repo(
|
||||
locked=locked, cache_dir=local_odb.cache_dir
|
||||
) as repo:
|
||||
used_objs = defaultdict(set)
|
||||
used_obj_ids = defaultdict(set)
|
||||
rev = repo.get_rev()
|
||||
if locked and self.def_repo.get(self.PARAM_REV_LOCK) is None:
|
||||
self.def_repo[self.PARAM_REV_LOCK] = rev
|
||||
|
||||
path_info = PathInfo(repo.root_dir) / str(self.def_path)
|
||||
try:
|
||||
for odb, objs in repo.used_objs(
|
||||
[os.fspath(path_info)],
|
||||
force=True,
|
||||
jobs=kwargs.get("jobs"),
|
||||
recursive=True,
|
||||
).items():
|
||||
if odb is None:
|
||||
odb = repo.cloud.get_remote().odb
|
||||
self._check_circular_import(odb)
|
||||
used_objs[odb].update(objs)
|
||||
except (NoRemoteError, NoOutputOrStageError):
|
||||
pass
|
||||
if not obj_only:
|
||||
try:
|
||||
for odb, obj_ids in repo.used_objs(
|
||||
[os.fspath(path_info)],
|
||||
force=True,
|
||||
jobs=kwargs.get("jobs"),
|
||||
recursive=True,
|
||||
).items():
|
||||
if odb is None:
|
||||
odb = repo.cloud.get_remote_odb()
|
||||
odb.read_only = True
|
||||
self._check_circular_import(odb, obj_ids)
|
||||
used_obj_ids[odb].update(obj_ids)
|
||||
except (NoRemoteError, NoOutputOrStageError):
|
||||
pass
|
||||
|
||||
try:
|
||||
staged_obj = stage(
|
||||
staging, staged_obj = stage(
|
||||
local_odb,
|
||||
path_info,
|
||||
repo.repo_fs,
|
||||
@@ -137,45 +142,61 @@ class RepoDependency(Dependency):
|
||||
raise PathMissingError(
|
||||
self.def_path, self.def_repo[self.PARAM_URL]
|
||||
) from exc
|
||||
staging = copy(staging)
|
||||
staging.read_only = True
|
||||
|
||||
self._staged_objs[rev] = staged_obj
|
||||
git_odb = GitObjectDB(repo.repo_fs, repo.root_dir)
|
||||
used_objs[git_odb].add(staged_obj)
|
||||
return used_objs
|
||||
used_obj_ids[staging].add(staged_obj.hash_info)
|
||||
if isinstance(staged_obj, Tree):
|
||||
used_obj_ids[staging].update(
|
||||
entry.hash_info for _, entry in staged_obj
|
||||
)
|
||||
return used_obj_ids, staged_obj
|
||||
|
||||
def _check_circular_import(self, odb):
|
||||
def _check_circular_import(self, odb, obj_ids):
|
||||
from dvc.exceptions import CircularImportError
|
||||
from dvc.fs.repo import RepoFileSystem
|
||||
from dvc.objects.db.reference import ReferenceObjectDB
|
||||
from dvc.objects.tree import Tree
|
||||
|
||||
if not odb or not isinstance(odb.fs, RepoFileSystem):
|
||||
if not isinstance(odb, ReferenceObjectDB):
|
||||
return
|
||||
|
||||
self_url = self.repo.url or self.repo.root_dir
|
||||
if odb.fs.repo_url is not None and odb.fs.repo_url == self_url:
|
||||
raise CircularImportError(self, odb.fs.repo_url, self_url)
|
||||
def iter_objs():
|
||||
for hash_info in obj_ids:
|
||||
if hash_info.isdir:
|
||||
tree = Tree.load(odb, hash_info)
|
||||
yield from (odb.get(entry.hash_info) for _, entry in tree)
|
||||
else:
|
||||
yield odb.get(hash_info)
|
||||
|
||||
checked_urls = set()
|
||||
for obj in iter_objs():
|
||||
if not isinstance(obj.fs, RepoFileSystem):
|
||||
continue
|
||||
if (
|
||||
obj.fs.repo_url in checked_urls
|
||||
or obj.fs.root_dir in checked_urls
|
||||
):
|
||||
continue
|
||||
self_url = self.repo.url or self.repo.root_dir
|
||||
if (
|
||||
obj.fs.repo_url is not None
|
||||
and obj.fs.repo_url == self_url
|
||||
or obj.fs.root_dir == self.repo.root_dir
|
||||
):
|
||||
raise CircularImportError(self, obj.fs.repo_url, self_url)
|
||||
checked_urls.update([obj.fs.repo_url, obj.fs.root_dir])
|
||||
|
||||
def get_obj(self, filter_info=None, **kwargs):
|
||||
from dvc.objects.stage import stage
|
||||
|
||||
odb = self.repo.odb.local
|
||||
locked = kwargs.pop("locked", True)
|
||||
with self._make_repo(locked=locked, cache_dir=odb.cache_dir) as repo:
|
||||
rev = repo.get_rev()
|
||||
if locked and self.def_repo.get(self.PARAM_REV_LOCK) is None:
|
||||
self.def_repo[self.PARAM_REV_LOCK] = rev
|
||||
obj = self._staged_objs.get(rev)
|
||||
if obj is not None:
|
||||
return obj
|
||||
|
||||
path_info = PathInfo(repo.root_dir) / str(self.def_path)
|
||||
obj = stage(
|
||||
odb,
|
||||
path_info,
|
||||
repo.repo_fs,
|
||||
odb.fs.PARAM_CHECKSUM,
|
||||
)
|
||||
self._staged_objs[rev] = obj
|
||||
return obj
|
||||
locked = kwargs.get("locked", True)
|
||||
rev = self._get_rev(locked=locked)
|
||||
if rev in self._staged_objs:
|
||||
return self._staged_objs[rev]
|
||||
_, obj = self._get_used_and_obj(
|
||||
obj_only=True, filter_info=filter_info, **kwargs
|
||||
)
|
||||
return obj
|
||||
|
||||
def _make_repo(self, locked=True, **kwargs):
|
||||
from dvc.external_repo import external_repo
|
||||
|
||||
+2
-4
@@ -200,9 +200,7 @@ class SingleStageFile(FileMixin):
|
||||
assert not isinstance(stage, PipelineStage)
|
||||
if self.verify:
|
||||
check_dvcfile_path(self.repo, self.path)
|
||||
logger.debug(
|
||||
"Saving information to '{file}'.".format(file=relpath(self.path))
|
||||
)
|
||||
logger.debug(f"Saving information to '{relpath(self.path)}'.")
|
||||
dump_yaml(self.path, serialize.to_single_stage_file(stage))
|
||||
self.repo.scm.track_file(self.relpath)
|
||||
|
||||
@@ -300,7 +298,7 @@ class PipelineFile(FileMixin):
|
||||
if not self.exists():
|
||||
return
|
||||
|
||||
with open(self.path, "r", encoding="utf-8") as f:
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
d = parse_yaml_for_update(f.read(), self.path)
|
||||
|
||||
self.validate(d, self.path)
|
||||
|
||||
@@ -7,3 +7,6 @@ DVCLIVE_SUMMARY = "DVCLIVE_SUMMARY"
|
||||
DVCLIVE_HTML = "DVCLIVE_HTML"
|
||||
DVCLIVE_RESUME = "DVCLIVE_RESUME"
|
||||
DVC_IGNORE_ISATTY = "DVC_IGNORE_ISATTY"
|
||||
DVC_EXP_GIT_REMOTE = "DVC_EXP_GIT_REMOTE"
|
||||
DVC_EXP_AUTO_PUSH = "DVC_EXP_AUTO_PUSH"
|
||||
DVC_NO_ANALYTICS = "DVC_NO_ANALYTICS"
|
||||
|
||||
+9
-37
@@ -1,5 +1,4 @@
|
||||
"""Exceptions raised by the dvc."""
|
||||
from typing import List
|
||||
|
||||
|
||||
class DvcException(Exception):
|
||||
@@ -173,36 +172,6 @@ class BadMetricError(DvcException):
|
||||
)
|
||||
|
||||
|
||||
class MetricsError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
class NoMetricsParsedError(MetricsError):
|
||||
def __init__(self, command):
|
||||
super().__init__(
|
||||
f"Could not parse {command} files. Use `-v` option to see more "
|
||||
"details."
|
||||
)
|
||||
|
||||
|
||||
class NoMetricsFoundError(MetricsError):
|
||||
def __init__(self, command, run_options):
|
||||
super().__init__(
|
||||
f"No {command} files in this repository. "
|
||||
f"Use `{run_options}` options for "
|
||||
f"`dvc run` to mark stage outputs as {command}."
|
||||
)
|
||||
|
||||
|
||||
class MetricDoesNotExistError(MetricsError):
|
||||
def __init__(self, targets: List[str]):
|
||||
if len(targets) == 1:
|
||||
msg = "'{}' does not exist."
|
||||
else:
|
||||
msg = "'{}' do not exist."
|
||||
super().__init__(msg.format(", ".join(targets)))
|
||||
|
||||
|
||||
class RecursiveAddingWhileUsingFilename(DvcException):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
@@ -259,18 +228,21 @@ class GitHookAlreadyExistsError(DvcException):
|
||||
)
|
||||
|
||||
|
||||
class DownloadError(DvcException):
|
||||
class FileTransferError(DvcException):
|
||||
_METHOD = "transfer"
|
||||
|
||||
def __init__(self, amount):
|
||||
self.amount = amount
|
||||
|
||||
super().__init__(f"{amount} files failed to download")
|
||||
super().__init__(f"{amount} files failed to {self._METHOD}")
|
||||
|
||||
|
||||
class UploadError(DvcException):
|
||||
def __init__(self, amount):
|
||||
self.amount = amount
|
||||
class DownloadError(FileTransferError):
|
||||
_METHOD = "download"
|
||||
|
||||
super().__init__(f"{amount} files failed to upload")
|
||||
|
||||
class UploadError(FileTransferError):
|
||||
_METHOD = "upload"
|
||||
|
||||
|
||||
class CheckoutError(DvcException):
|
||||
|
||||
+13
-9
@@ -42,14 +42,6 @@ def external_repo(
|
||||
config = _get_remote_config(url) if os.path.isdir(url) else {}
|
||||
config.update(cache_config)
|
||||
|
||||
def make_repo(path, **_kwargs):
|
||||
_config = cache_config.copy()
|
||||
if os.path.isdir(url):
|
||||
rel = os.path.relpath(path, _kwargs["scm"].root_dir)
|
||||
repo_path = os.path.join(url, rel)
|
||||
_config.update(_get_remote_config(repo_path))
|
||||
return Repo(path, config=_config, **_kwargs)
|
||||
|
||||
root_dir = path if for_write else os.path.realpath(path)
|
||||
repo_kwargs = dict(
|
||||
root_dir=root_dir,
|
||||
@@ -57,7 +49,7 @@ def external_repo(
|
||||
scm=None if for_write else Git(root_dir),
|
||||
rev=None if for_write else rev,
|
||||
config=config,
|
||||
repo_factory=make_repo,
|
||||
repo_factory=erepo_factory(url, cache_config),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -87,6 +79,18 @@ def external_repo(
|
||||
_remove(path)
|
||||
|
||||
|
||||
def erepo_factory(url, cache_config, *args, **kwargs):
|
||||
def make_repo(path, **_kwargs):
|
||||
_config = cache_config.copy()
|
||||
if os.path.isdir(url):
|
||||
rel = os.path.relpath(path, _kwargs["scm"].root_dir)
|
||||
repo_path = os.path.join(url, rel)
|
||||
_config.update(_get_remote_config(repo_path))
|
||||
return Repo(path, config=_config, **_kwargs)
|
||||
|
||||
return make_repo
|
||||
|
||||
|
||||
CLONES: Dict[str, str] = {}
|
||||
CACHE_DIRS: Dict[str, str] = {}
|
||||
|
||||
|
||||
+9
-3
@@ -32,15 +32,21 @@ FS_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def get_fs_cls(remote_conf):
|
||||
scheme = urlparse(remote_conf["url"]).scheme
|
||||
def get_fs_cls(remote_conf, scheme=None):
|
||||
if not scheme:
|
||||
scheme = urlparse(remote_conf["url"]).scheme
|
||||
return FS_MAP.get(scheme, LocalFileSystem)
|
||||
|
||||
|
||||
def get_fs_config(config, **kwargs):
|
||||
name = kwargs.get("name")
|
||||
if name:
|
||||
remote_conf = config["remote"][name.lower()]
|
||||
try:
|
||||
remote_conf = config["remote"][name.lower()]
|
||||
except KeyError:
|
||||
from dvc.config import RemoteNotFoundError
|
||||
|
||||
raise RemoteNotFoundError(f"remote '{name}' doesn't exist")
|
||||
else:
|
||||
remote_conf = kwargs
|
||||
return _resolve_remote_refs(config, remote_conf)
|
||||
|
||||
+18
-57
@@ -1,10 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
|
||||
from fsspec.asyn import fsspec_loop
|
||||
from fsspec.utils import infer_storage_options
|
||||
from funcy import cached_property, memoize, wrap_prop
|
||||
|
||||
@@ -23,47 +21,6 @@ _DEFAULT_CREDS_STEPS = (
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _temp_event_loop():
|
||||
"""When trying to initialize azure filesystem instances
|
||||
with DefaultCredentials, the authentication process requires
|
||||
to have an access to a separate event loop. The normal calls
|
||||
are run in a separate loop managed by the fsspec, but the
|
||||
DefaultCredentials assumes that the callee is managing their
|
||||
own event loop. This function checks whether is there any
|
||||
event loop set, and if not it creates a temporary event loop
|
||||
and set it. After the context is finalized, it restores the
|
||||
original event loop back (if there is any)."""
|
||||
|
||||
try:
|
||||
original_loop = asyncio.get_event_loop()
|
||||
original_policy = asyncio.get_event_loop_policy()
|
||||
except RuntimeError:
|
||||
original_loop = None
|
||||
original_policy = None
|
||||
|
||||
# From 3.8>= and onwards, asyncio changed the default
|
||||
# loop policy for windows to use proactor loops instead
|
||||
# of selector based ones. Due to that, proxied connections
|
||||
# doesn't work with the aiohttp and this is most likely an
|
||||
# upstream bug that needs to be solved outside of DVC. Until
|
||||
# such issue is resolved, we need to manage this;
|
||||
# https://github.com/aio-libs/aiohttp/issues/4536
|
||||
if sys.version_info >= (3, 8) and os.name == "nt":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
loop = original_loop or asyncio.new_event_loop()
|
||||
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
yield
|
||||
finally:
|
||||
if original_loop is None:
|
||||
loop.close()
|
||||
asyncio.set_event_loop(original_loop)
|
||||
asyncio.set_event_loop_policy(original_policy)
|
||||
|
||||
|
||||
class AzureAuthError(DvcException):
|
||||
pass
|
||||
|
||||
@@ -149,9 +106,22 @@ class AzureFileSystem(ObjectFSWrapper):
|
||||
and not any_secondary
|
||||
and not config.get("allow_anonymous_login", False)
|
||||
):
|
||||
login_info["credential"] = DefaultAzureCredential(
|
||||
exclude_interactive_browser_credential=False
|
||||
)
|
||||
with fsspec_loop():
|
||||
login_info["credential"] = DefaultAzureCredential(
|
||||
exclude_interactive_browser_credential=False,
|
||||
exclude_environment_credential=config.get(
|
||||
"exclude_environment_credential", False
|
||||
),
|
||||
exclude_visual_studio_code_credential=config.get(
|
||||
"exclude_visual_studio_code_credential", False
|
||||
),
|
||||
exclude_shared_token_cache_credential=config.get(
|
||||
"exclude_shared_token_cache_credential", False
|
||||
),
|
||||
exclude_managed_identity_credential=config.get(
|
||||
"exclude_managed_identity_credential", False
|
||||
),
|
||||
)
|
||||
|
||||
for login_method, required_keys in [ # noqa
|
||||
("connection string", ["connection_string"]),
|
||||
@@ -182,19 +152,10 @@ class AzureFileSystem(ObjectFSWrapper):
|
||||
from azure.core.exceptions import AzureError
|
||||
|
||||
try:
|
||||
with _temp_event_loop():
|
||||
file_system = AzureBlobFileSystem(**self.fs_args)
|
||||
return AzureBlobFileSystem(**self.fs_args)
|
||||
except (ValueError, AzureError) as e:
|
||||
raise AzureAuthError(
|
||||
f"Authentication to Azure Blob Storage via {self.login_method}"
|
||||
" failed.\nLearn more about configuration settings at"
|
||||
f" {format_link('https://man.dvc.org/remote/modify')}"
|
||||
) from e
|
||||
|
||||
return file_system
|
||||
|
||||
def open(
|
||||
self, path_info, mode="r", **kwargs
|
||||
): # pylint: disable=arguments-differ
|
||||
with _temp_event_loop():
|
||||
return self.fs.open(self._with_bucket(path_info), mode=mode)
|
||||
|
||||
+6
-1
@@ -59,6 +59,11 @@ class BaseFileSystem:
|
||||
|
||||
self.jobs = kwargs.get("jobs") or self._JOBS
|
||||
self.hash_jobs = kwargs.get("checksum_jobs") or self.HASH_JOBS
|
||||
self._config = kwargs
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return self._config
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str):
|
||||
@@ -187,7 +192,7 @@ class BaseFileSystem:
|
||||
def remove(self, path_info):
|
||||
raise RemoteActionNotImplemented("remove", self.scheme)
|
||||
|
||||
def makedirs(self, path_info):
|
||||
def makedirs(self, path_info, **kwargs):
|
||||
"""Optional: Implement only if the remote needs to create
|
||||
directories before copying/linking/moving data
|
||||
"""
|
||||
|
||||
+26
-12
@@ -32,6 +32,10 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
super().__init__(**kwargs)
|
||||
self.repo = kwargs["repo"]
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _find_outs(self, path, *args, **kwargs):
|
||||
outs = self.repo.find_outs_by_path(path, *args, **kwargs)
|
||||
|
||||
@@ -51,14 +55,14 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
# NOTE: use string paths here for performance reasons
|
||||
key = tuple(relpath(path_info, out.path_info).split(os.sep))
|
||||
out.get_dir_cache(remote=remote)
|
||||
if out.obj is None:
|
||||
raise FileNotFoundError
|
||||
obj = out.obj.trie.get(key)
|
||||
if obj:
|
||||
return obj.hash_info
|
||||
raise FileNotFoundError
|
||||
|
||||
def open( # type: ignore
|
||||
self, path: PathInfo, mode="r", encoding=None, remote=None, **kwargs
|
||||
): # pylint: disable=arguments-differ
|
||||
def _get_fs_path(self, path: PathInfo, remote=None):
|
||||
try:
|
||||
outs = self._find_outs(path, strict=False)
|
||||
except OutputNotFoundError as exc:
|
||||
@@ -78,33 +82,37 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
from dvc.config import NoRemoteError
|
||||
|
||||
try:
|
||||
remote_obj = self.repo.cloud.get_remote(remote)
|
||||
remote_odb = self.repo.cloud.get_remote_odb(remote)
|
||||
except NoRemoteError as exc:
|
||||
raise FileNotFoundError from exc
|
||||
if out.is_dir_checksum:
|
||||
checksum = self._get_granular_hash(path, out).value
|
||||
else:
|
||||
checksum = out.hash_info.value
|
||||
remote_info = remote_obj.odb.hash_to_path_info(checksum)
|
||||
return remote_obj.fs.open(
|
||||
remote_info, mode=mode, encoding=encoding
|
||||
)
|
||||
remote_info = remote_odb.hash_to_path_info(checksum)
|
||||
return remote_odb.fs, remote_info
|
||||
|
||||
if out.is_dir_checksum:
|
||||
checksum = self._get_granular_hash(path, out).value
|
||||
cache_path = out.odb.hash_to_path_info(checksum).url
|
||||
else:
|
||||
cache_path = out.cache_path
|
||||
return open(cache_path, mode=mode, encoding=encoding)
|
||||
return out.odb.fs, cache_path
|
||||
|
||||
def exists(self, path): # pylint: disable=arguments-differ
|
||||
def open( # type: ignore
|
||||
self, path: PathInfo, mode="r", encoding=None, **kwargs
|
||||
): # pylint: disable=arguments-renamed
|
||||
fs, fspath = self._get_fs_path(path, **kwargs)
|
||||
return fs.open(fspath, mode=mode, encoding=encoding)
|
||||
|
||||
def exists(self, path): # pylint: disable=arguments-renamed
|
||||
try:
|
||||
self.metadata(path)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def isdir(self, path): # pylint: disable=arguments-differ
|
||||
def isdir(self, path): # pylint: disable=arguments-renamed
|
||||
try:
|
||||
meta = self.metadata(path)
|
||||
return meta.isdir
|
||||
@@ -127,7 +135,7 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
|
||||
def isfile(self, path): # pylint: disable=arguments-differ
|
||||
def isfile(self, path): # pylint: disable=arguments-renamed
|
||||
try:
|
||||
meta = self.metadata(path)
|
||||
return meta.isfile
|
||||
@@ -247,3 +255,9 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
ret[obj.hash_info.name] = obj.hash_info.value
|
||||
|
||||
return ret
|
||||
|
||||
def _download(self, from_info, to_file, **kwargs):
|
||||
fs, path = self._get_fs_path(from_info)
|
||||
fs._download( # pylint: disable=protected-access
|
||||
path, to_file, **kwargs
|
||||
)
|
||||
|
||||
+89
-14
@@ -7,6 +7,7 @@ from funcy import cached_property
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
from .base import BaseFileSystem
|
||||
from .local import LocalFileSystem
|
||||
|
||||
|
||||
# pylint: disable=no-member
|
||||
@@ -22,21 +23,20 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
def fs(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@lru_cache(512)
|
||||
def _with_bucket(self, path):
|
||||
if isinstance(path, self.PATH_CLS):
|
||||
return f"{path.bucket}/{path.path}"
|
||||
return path
|
||||
return str(path)
|
||||
|
||||
def _strip_bucket(self, entry):
|
||||
try:
|
||||
bucket, path = entry.split("/", 1)
|
||||
except ValueError:
|
||||
# If there is no path attached, only returns
|
||||
# the bucket (top-level).
|
||||
bucket, path = entry, None
|
||||
return entry
|
||||
|
||||
return path or bucket
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
options = infer_storage_options(urlpath)
|
||||
options.pop("path", None)
|
||||
options.pop("protocol", None)
|
||||
return options
|
||||
|
||||
def _strip_buckets(self, entries, detail=False):
|
||||
for entry in entries:
|
||||
@@ -86,6 +86,7 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
return self.fs.open(self._with_bucket(path_info), mode=mode)
|
||||
|
||||
def copy(self, from_info, to_info):
|
||||
self.makedirs(to_info.parent)
|
||||
self.fs.copy(self._with_bucket(from_info), self._with_bucket(to_info))
|
||||
|
||||
def exists(self, path_info) -> bool:
|
||||
@@ -109,8 +110,11 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
for file in self.find(path_info, **kwargs):
|
||||
yield path_info.replace(path=file)
|
||||
|
||||
def move(self, from_info, to_info):
|
||||
self.fs.move(self._with_bucket(from_info), self._with_bucket(to_info))
|
||||
|
||||
def remove(self, path_info):
|
||||
self.fs.rm(self._with_bucket(path_info))
|
||||
self.fs.rm_file(self._with_bucket(path_info))
|
||||
|
||||
def info(self, path_info):
|
||||
info = self.fs.info(self._with_bucket(path_info)).copy()
|
||||
@@ -118,18 +122,25 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
info["name"] = self._strip_bucket(info["name"])
|
||||
return info
|
||||
|
||||
def makedirs(self, path_info, **kwargs):
|
||||
self.fs.makedirs(
|
||||
self._with_bucket(path_info), exist_ok=kwargs.pop("exist_ok", True)
|
||||
)
|
||||
|
||||
def _upload_fobj(self, fobj, to_info, size=None):
|
||||
self.makedirs(to_info.parent)
|
||||
with self.open(to_info, "wb") as fdest:
|
||||
shutil.copyfileobj(fobj, fdest, length=fdest.blocksize)
|
||||
|
||||
def _upload(
|
||||
self, from_file, to_info, name=None, no_progress_bar=False, **kwargs
|
||||
):
|
||||
self.makedirs(to_info.parent)
|
||||
size = os.path.getsize(from_file)
|
||||
with open(from_file, "rb") as fobj:
|
||||
self.upload_fobj(
|
||||
fobj,
|
||||
self._with_bucket(to_info),
|
||||
to_info,
|
||||
size=size,
|
||||
desc=name,
|
||||
no_progress_bar=no_progress_bar,
|
||||
@@ -158,6 +169,29 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
class ObjectFSWrapper(FSSpecWrapper):
|
||||
TRAVERSE_PREFIX_LEN = 3
|
||||
|
||||
@lru_cache(512)
|
||||
def _with_bucket(self, path):
|
||||
if isinstance(path, self.PATH_CLS):
|
||||
return f"{path.bucket}/{path.path}"
|
||||
return path
|
||||
|
||||
def _strip_bucket(self, entry):
|
||||
try:
|
||||
bucket, path = entry.split("/", 1)
|
||||
except ValueError:
|
||||
# If there is no path attached, only returns
|
||||
# the bucket (top-level).
|
||||
bucket, path = entry, None
|
||||
return path or bucket
|
||||
|
||||
def makedirs(self, path_info, **kwargs):
|
||||
# For object storages make this method a no-op. The original
|
||||
# fs.makedirs() method will only check if the bucket exists
|
||||
# and create if it doesn't though we don't want to support
|
||||
# that behavior, and the check will cost some time so we'll
|
||||
# simply ignore all mkdir()/makedirs() calls.
|
||||
return None
|
||||
|
||||
def _isdir(self, path_info):
|
||||
# Directory in object storages are interpreted differently
|
||||
# among different fsspec providers, so this logic is a temporary
|
||||
@@ -183,7 +217,7 @@ class ObjectFSWrapper(FSSpecWrapper):
|
||||
files = self.fs.find(path, detail=detail)
|
||||
|
||||
if detail:
|
||||
files = files.values()
|
||||
files = list(files.values())
|
||||
|
||||
# When calling find() on a file, it returns the same file in a list.
|
||||
# For object-based storages, the same behavior applies to empty
|
||||
@@ -194,3 +228,44 @@ class ObjectFSWrapper(FSSpecWrapper):
|
||||
return None
|
||||
|
||||
yield from self._strip_buckets(files, detail=detail)
|
||||
|
||||
|
||||
_LOCAL_FS = LocalFileSystem()
|
||||
|
||||
|
||||
class CallbackMixin:
|
||||
"""Use the native ``get_file()``/``put_file()`` APIs
|
||||
if the target filesystem supports callbacks."""
|
||||
|
||||
def _upload(
|
||||
self, from_file, to_info, name=None, no_progress_bar=False, **pbar_args
|
||||
):
|
||||
with Tqdm(
|
||||
desc=name,
|
||||
disable=no_progress_bar,
|
||||
bytes=True,
|
||||
total=-1,
|
||||
**pbar_args,
|
||||
) as pbar:
|
||||
self.fs.put_file(
|
||||
os.fspath(from_file),
|
||||
self._with_bucket(to_info),
|
||||
callback=pbar.as_callback(_LOCAL_FS, from_file),
|
||||
)
|
||||
self.fs.invalidate_cache(self._with_bucket(to_info.parent))
|
||||
|
||||
def _download(
|
||||
self, from_info, to_file, name=None, no_progress_bar=False, **pbar_args
|
||||
):
|
||||
with Tqdm(
|
||||
desc=name,
|
||||
disable=no_progress_bar,
|
||||
bytes=True,
|
||||
total=-1,
|
||||
**pbar_args,
|
||||
) as pbar:
|
||||
self.fs.get_file(
|
||||
self._with_bucket(from_info),
|
||||
os.fspath(to_file),
|
||||
callback=pbar.as_callback(self, from_info),
|
||||
)
|
||||
|
||||
+19
-364
@@ -1,25 +1,18 @@
|
||||
import errno
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from funcy import cached_property, retry, wrap_prop, wrap_with
|
||||
from funcy.py3 import cat
|
||||
from funcy import cached_property, retry, wrap_prop
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.path_info import CloudURLInfo
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils import format_link, tmp_fname
|
||||
from dvc.utils.stream import IterStream
|
||||
|
||||
from .base import BaseFileSystem
|
||||
from .fsspec_wrapper import CallbackMixin, FSSpecWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"
|
||||
@@ -86,7 +79,9 @@ class GDriveURLInfo(CloudURLInfo):
|
||||
self._spath = re.sub("/{2,}", "/", self._spath.rstrip("/"))
|
||||
|
||||
|
||||
class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
class GDriveFileSystem(
|
||||
CallbackMixin, FSSpecWrapper
|
||||
): # pylint:disable=abstract-method
|
||||
scheme = Schemes.GDRIVE
|
||||
PATH_CLS = GDriveURLInfo
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
@@ -214,9 +209,9 @@ class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
|
||||
@wrap_prop(threading.RLock())
|
||||
@cached_property
|
||||
def _drive(self):
|
||||
def fs(self):
|
||||
from pydrive2.auth import GoogleAuth
|
||||
from pydrive2.drive import GoogleDrive
|
||||
from pydrive2.fs import GDriveFileSystem as _GDriveFileSystem
|
||||
|
||||
temporary_save_path = self._gdrive_user_credentials_path
|
||||
is_credentials_temp = os.getenv(
|
||||
@@ -291,359 +286,19 @@ class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
if is_credentials_temp:
|
||||
os.remove(temporary_save_path)
|
||||
|
||||
return GoogleDrive(gauth)
|
||||
|
||||
@wrap_prop(threading.RLock())
|
||||
@cached_property
|
||||
def _ids_cache(self):
|
||||
cache = {
|
||||
"dirs": defaultdict(list),
|
||||
"ids": {},
|
||||
"root_id": self._get_item_id(
|
||||
self.path_info,
|
||||
use_cache=False,
|
||||
hint="Confirm the directory exists and you can access it.",
|
||||
),
|
||||
}
|
||||
|
||||
self._cache_path_id(self._path, cache["root_id"], cache=cache)
|
||||
|
||||
for item in self._gdrive_list(
|
||||
"'{}' in parents and trashed=false".format(cache["root_id"])
|
||||
):
|
||||
item_path = (self.path_info / item["title"]).path
|
||||
self._cache_path_id(item_path, item["id"], cache=cache)
|
||||
|
||||
return cache
|
||||
|
||||
def _cache_path_id(self, path, *item_ids, cache=None):
|
||||
cache = cache or self._ids_cache
|
||||
for item_id in item_ids:
|
||||
cache["dirs"][path].append(item_id)
|
||||
cache["ids"][item_id] = path
|
||||
|
||||
@cached_property
|
||||
def _list_params(self):
|
||||
params = {"corpora": "default"}
|
||||
if self._bucket != "root" and self._bucket != "appDataFolder":
|
||||
drive_id = self._gdrive_shared_drive_id(self._bucket)
|
||||
if drive_id:
|
||||
logger.debug(
|
||||
"GDrive remote '{}' is using shared drive id '{}'.".format(
|
||||
self.path_info, drive_id
|
||||
)
|
||||
)
|
||||
params["driveId"] = drive_id
|
||||
params["corpora"] = "drive"
|
||||
return params
|
||||
|
||||
@_gdrive_retry
|
||||
def _gdrive_shared_drive_id(self, item_id):
|
||||
from pydrive2.files import ApiRequestError
|
||||
|
||||
param = {"id": item_id}
|
||||
# it does not create a file on the remote
|
||||
item = self._drive.CreateFile(param)
|
||||
# ID of the shared drive the item resides in.
|
||||
# Only populated for items in shared drives.
|
||||
try:
|
||||
item.FetchMetadata("driveId")
|
||||
except ApiRequestError as exc:
|
||||
error_code = exc.error.get("code", 0)
|
||||
if error_code == 404:
|
||||
raise DvcException(
|
||||
"'{}' for '{}':\n\n"
|
||||
"1. Confirm the directory exists and you can access it.\n"
|
||||
"2. Make sure that credentials in '{}'\n"
|
||||
" are correct for this remote e.g. "
|
||||
"use the `gdrive_user_credentials_file` config\n"
|
||||
" option if you use multiple GDrive remotes with "
|
||||
"different email accounts.\n\nDetails".format(
|
||||
item_id, self.path_info, self.credentials_location
|
||||
)
|
||||
) from exc
|
||||
raise
|
||||
|
||||
return item.get("driveId", None)
|
||||
|
||||
@_gdrive_retry
|
||||
def _gdrive_upload_fobj(self, fobj, parent_id, title):
|
||||
item = self._drive.CreateFile(
|
||||
{"title": title, "parents": [{"id": parent_id}]}
|
||||
)
|
||||
item.content = fobj
|
||||
item.Upload()
|
||||
return item
|
||||
|
||||
@_gdrive_retry
|
||||
def _gdrive_download_file(
|
||||
self, item_id, to_file, progress_desc, no_progress_bar
|
||||
):
|
||||
param = {"id": item_id}
|
||||
# it does not create a file on the remote
|
||||
gdrive_file = self._drive.CreateFile(param)
|
||||
|
||||
with Tqdm(
|
||||
desc=progress_desc,
|
||||
disable=no_progress_bar,
|
||||
bytes=True,
|
||||
# explicit `bar_format` as `total` will be set by `update_to`
|
||||
bar_format=Tqdm.BAR_FMT_DEFAULT,
|
||||
) as pbar:
|
||||
gdrive_file.GetContentFile(to_file, callback=pbar.update_to)
|
||||
|
||||
@contextmanager
|
||||
@_gdrive_retry
|
||||
def open(self, path_info, mode="r", encoding=None, **kwargs):
|
||||
assert mode in {"r", "rt", "rb"}
|
||||
|
||||
item_id = self._get_item_id(path_info)
|
||||
param = {"id": item_id}
|
||||
# it does not create a file on the remote
|
||||
gdrive_file = self._drive.CreateFile(param)
|
||||
fd = gdrive_file.GetContentIOBuffer()
|
||||
stream = IterStream(iter(fd))
|
||||
|
||||
if mode != "rb":
|
||||
stream = io.TextIOWrapper(stream, encoding=encoding)
|
||||
|
||||
yield stream
|
||||
|
||||
@_gdrive_retry
|
||||
def gdrive_delete_file(self, item_id):
|
||||
from pydrive2.files import ApiRequestError
|
||||
|
||||
param = {"id": item_id}
|
||||
# it does not create a file on the remote
|
||||
item = self._drive.CreateFile(param)
|
||||
|
||||
try:
|
||||
item.Trash() if self._trash_only else item.Delete()
|
||||
except ApiRequestError as exc:
|
||||
http_error_code = exc.error.get("code", 0)
|
||||
if (
|
||||
http_error_code == 403
|
||||
and self._list_params["corpora"] == "drive"
|
||||
and exc.GetField("location") == "file.permissions"
|
||||
):
|
||||
raise DvcException(
|
||||
"Insufficient permissions to {}. You should have {} "
|
||||
"access level for the used shared drive. More details "
|
||||
"at {}.".format(
|
||||
"move the file into Trash"
|
||||
if self._trash_only
|
||||
else "permanently delete the file",
|
||||
"Manager or Content Manager"
|
||||
if self._trash_only
|
||||
else "Manager",
|
||||
"https://support.google.com/a/answer/7337554",
|
||||
)
|
||||
) from exc
|
||||
raise
|
||||
|
||||
def _gdrive_list(self, query):
|
||||
param = {"q": query, "maxResults": 1000}
|
||||
param.update(self._list_params)
|
||||
file_list = self._drive.ListFile(param)
|
||||
|
||||
# Isolate and decorate fetching of remote drive items in pages.
|
||||
get_list = _gdrive_retry(lambda: next(file_list, None))
|
||||
|
||||
# Fetch pages until None is received, lazily flatten the thing.
|
||||
return cat(iter(get_list, None))
|
||||
|
||||
@_gdrive_retry
|
||||
def _gdrive_create_dir(self, parent_id, title):
|
||||
parent = {"id": parent_id}
|
||||
item = self._drive.CreateFile(
|
||||
{"title": title, "parents": [parent], "mimeType": FOLDER_MIME_TYPE}
|
||||
)
|
||||
item.Upload()
|
||||
return item
|
||||
|
||||
@wrap_with(threading.RLock())
|
||||
def _create_dir(self, parent_id, title, remote_path):
|
||||
cached = self._ids_cache["dirs"].get(remote_path)
|
||||
if cached:
|
||||
return cached[0]
|
||||
|
||||
item = self._gdrive_create_dir(parent_id, title)
|
||||
|
||||
if parent_id == self._ids_cache["root_id"]:
|
||||
self._cache_path_id(remote_path, item["id"])
|
||||
|
||||
return item["id"]
|
||||
|
||||
def _get_remote_item_ids(self, parent_ids, title):
|
||||
if not parent_ids:
|
||||
return None
|
||||
query = "trashed=false and ({})".format(
|
||||
" or ".join(
|
||||
f"'{parent_id}' in parents" for parent_id in parent_ids
|
||||
)
|
||||
)
|
||||
query += " and title='{}'".format(title.replace("'", "\\'"))
|
||||
|
||||
# GDrive list API is case insensitive, we need to compare
|
||||
# all results and pick the ones with the right title
|
||||
return [
|
||||
item["id"]
|
||||
for item in self._gdrive_list(query)
|
||||
if item["title"] == title
|
||||
]
|
||||
|
||||
def _get_cached_item_ids(self, path, use_cache):
|
||||
if not path:
|
||||
return [self._bucket]
|
||||
if use_cache:
|
||||
return self._ids_cache["dirs"].get(path, [])
|
||||
return []
|
||||
|
||||
def _path_to_item_ids(self, path, create=False, use_cache=True):
|
||||
item_ids = self._get_cached_item_ids(path, use_cache)
|
||||
if item_ids:
|
||||
return item_ids
|
||||
|
||||
parent_path, title = posixpath.split(path)
|
||||
parent_ids = self._path_to_item_ids(parent_path, create, use_cache)
|
||||
item_ids = self._get_remote_item_ids(parent_ids, title)
|
||||
if item_ids:
|
||||
return item_ids
|
||||
|
||||
return (
|
||||
[self._create_dir(min(parent_ids), title, path)] if create else []
|
||||
return _GDriveFileSystem(
|
||||
self._with_bucket(self.path_info),
|
||||
gauth,
|
||||
trash_only=self._trash_only,
|
||||
)
|
||||
|
||||
def _get_item_id(self, path_info, create=False, use_cache=True, hint=None):
|
||||
assert path_info.bucket == self._bucket
|
||||
def _with_bucket(self, path):
|
||||
if isinstance(path, self.PATH_CLS):
|
||||
return posixpath.join(path.bucket, path.path)
|
||||
|
||||
item_ids = self._path_to_item_ids(path_info.path, create, use_cache)
|
||||
if item_ids:
|
||||
return min(item_ids)
|
||||
return super()._with_bucket(path)
|
||||
|
||||
assert not create
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), hint or path_info
|
||||
)
|
||||
|
||||
def exists(self, path_info) -> bool:
|
||||
try:
|
||||
self._get_item_id(path_info)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def _gdrive_list_ids(self, query_ids):
|
||||
query = " or ".join(
|
||||
f"'{query_id}' in parents" for query_id in query_ids
|
||||
)
|
||||
query = f"({query}) and trashed=false"
|
||||
return self._gdrive_list(query)
|
||||
|
||||
def find(self, path_info, detail=False, prefix=None):
|
||||
root_path = path_info.path
|
||||
seen_paths = set()
|
||||
|
||||
dir_ids = [self._ids_cache["ids"].copy()]
|
||||
while dir_ids:
|
||||
query_ids = {
|
||||
dir_id: dir_name
|
||||
for dir_id, dir_name in dir_ids.pop().items()
|
||||
if posixpath.commonpath([root_path, dir_name]) == root_path
|
||||
if dir_id not in seen_paths
|
||||
}
|
||||
if not query_ids:
|
||||
continue
|
||||
|
||||
seen_paths |= query_ids.keys()
|
||||
|
||||
new_query_ids = {}
|
||||
dir_ids.append(new_query_ids)
|
||||
for item in self._gdrive_list_ids(query_ids):
|
||||
parent_id = item["parents"][0]["id"]
|
||||
item_path = posixpath.join(query_ids[parent_id], item["title"])
|
||||
if item["mimeType"] == FOLDER_MIME_TYPE:
|
||||
new_query_ids[item["id"]] = item_path
|
||||
self._cache_path_id(item_path, item["id"])
|
||||
continue
|
||||
|
||||
if detail:
|
||||
yield {
|
||||
"type": "file",
|
||||
"name": item_path,
|
||||
"size": item["fileSize"],
|
||||
"checksum": item["md5Checksum"],
|
||||
}
|
||||
else:
|
||||
yield item_path
|
||||
|
||||
def ls(self, path_info, detail=False):
|
||||
cached = path_info.path in self._ids_cache["dirs"]
|
||||
if cached:
|
||||
dir_ids = self._ids_cache["dirs"][path_info.path]
|
||||
else:
|
||||
dir_ids = self._path_to_item_ids(path_info.path)
|
||||
|
||||
if not dir_ids:
|
||||
return None
|
||||
|
||||
root_path = path_info.path
|
||||
for item in self._gdrive_list_ids(dir_ids):
|
||||
item_path = posixpath.join(root_path, item["title"])
|
||||
if detail:
|
||||
if item["mimeType"] == FOLDER_MIME_TYPE:
|
||||
yield {"type": "directory", "name": item_path}
|
||||
else:
|
||||
yield {
|
||||
"type": "file",
|
||||
"name": item_path,
|
||||
"size": item["fileSize"],
|
||||
"checksum": item["md5Checksum"],
|
||||
}
|
||||
else:
|
||||
yield item_path
|
||||
|
||||
if not cached:
|
||||
self._cache_path_id(root_path, *dir_ids)
|
||||
|
||||
def walk_files(self, path_info, **kwargs):
|
||||
for file in self.find(path_info):
|
||||
yield path_info.replace(path=file)
|
||||
|
||||
def remove(self, path_info):
|
||||
item_id = self._get_item_id(path_info)
|
||||
self.gdrive_delete_file(item_id)
|
||||
|
||||
def info(self, path_info):
|
||||
item_id = self._get_item_id(path_info)
|
||||
gdrive_file = self._drive.CreateFile({"id": item_id})
|
||||
gdrive_file.FetchMetadata(fields="fileSize")
|
||||
return {"size": gdrive_file.get("fileSize"), "type": "file"}
|
||||
|
||||
def _upload_fobj(self, fobj, to_info, **kwargs):
|
||||
dirname = to_info.parent
|
||||
assert dirname
|
||||
parent_id = self._get_item_id(dirname, create=True)
|
||||
self._gdrive_upload_fobj(fobj, parent_id, to_info.name)
|
||||
|
||||
def _upload(
|
||||
self,
|
||||
from_file,
|
||||
to_info,
|
||||
name=None,
|
||||
no_progress_bar=False,
|
||||
**_kwargs,
|
||||
):
|
||||
with open(from_file, "rb") as fobj:
|
||||
self.upload_fobj(
|
||||
fobj,
|
||||
to_info,
|
||||
size=os.path.getsize(from_file),
|
||||
no_progress_bar=no_progress_bar,
|
||||
desc=name or to_info.name,
|
||||
)
|
||||
|
||||
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
|
||||
item_id = self._get_item_id(from_info)
|
||||
self._gdrive_download_file(item_id, to_file, name, no_progress_bar)
|
||||
def _upload_fobj(self, fobj, to_info, size: int = None):
|
||||
rpath = self._with_bucket(to_info)
|
||||
self.makedirs(os.path.dirname(rpath))
|
||||
return self.fs.upload_fobj(fobj, rpath, size=size)
|
||||
|
||||
+16
-2
@@ -36,8 +36,8 @@ class GitFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
return tuple(relparts)
|
||||
|
||||
def open(
|
||||
self, path, mode="r", encoding=None
|
||||
): # pylint: disable=arguments-differ
|
||||
self, path, mode="r", encoding=None, **kwargs
|
||||
): # pylint: disable=arguments-renamed
|
||||
# NOTE: this is incorrect, we need to use locale to determine default
|
||||
# encoding.
|
||||
encoding = encoding or "utf-8"
|
||||
@@ -126,3 +126,17 @@ class GitFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
for file in files:
|
||||
# NOTE: os.path.join is ~5.5 times slower
|
||||
yield f"{root}{os.sep}{file}"
|
||||
|
||||
def _download(
|
||||
self, from_info, to_file, name=None, no_progress_bar=False, **kwargs
|
||||
):
|
||||
import shutil
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
with open(to_file, "wb+") as to_fobj:
|
||||
with Tqdm.wrapattr(
|
||||
to_fobj, "write", desc=name, disable=no_progress_bar
|
||||
) as wrapped:
|
||||
with self.open(from_info, "rb", **kwargs) as from_fobj:
|
||||
shutil.copyfileobj(from_fobj, wrapped)
|
||||
|
||||
+1
-1
@@ -183,7 +183,7 @@ class HDFSFileSystem(BaseFileSystem):
|
||||
else:
|
||||
hdfs.delete_file(path_info.path)
|
||||
|
||||
def makedirs(self, path_info):
|
||||
def makedirs(self, path_info, **kwargs):
|
||||
with self.hdfs(path_info) as hdfs:
|
||||
# NOTE: fs.create_dir creates parents by default
|
||||
hdfs.create_dir(path_info.path)
|
||||
|
||||
+5
-3
@@ -79,8 +79,8 @@ class LocalFileSystem(BaseFileSystem):
|
||||
raise NotImplementedError
|
||||
remove(path_info)
|
||||
|
||||
def makedirs(self, path_info):
|
||||
makedirs(path_info, exist_ok=True)
|
||||
def makedirs(self, path_info, **kwargs):
|
||||
makedirs(path_info, exist_ok=kwargs.pop("exist_ok", True))
|
||||
|
||||
def isexec(self, path_info):
|
||||
mode = self.stat(path_info).st_mode
|
||||
@@ -90,7 +90,9 @@ class LocalFileSystem(BaseFileSystem):
|
||||
return os.stat(path)
|
||||
|
||||
def move(self, from_info, to_info):
|
||||
if from_info.scheme != "local" or to_info.scheme != "local":
|
||||
if (
|
||||
isinstance(from_info, PathInfo) and from_info.scheme != "local"
|
||||
) or (isinstance(to_info, PathInfo) and to_info.scheme != "local"):
|
||||
raise NotImplementedError
|
||||
|
||||
self.makedirs(to_info.parent)
|
||||
|
||||
+28
-27
@@ -1,34 +1,35 @@
|
||||
from .base import BaseFileSystem
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.path_info import CloudURLInfo
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .fsspec_wrapper import FSSpecWrapper
|
||||
|
||||
|
||||
class MemoryFileSystem(BaseFileSystem):
|
||||
scheme = "local"
|
||||
class MemoryFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
|
||||
scheme = Schemes.MEMORY
|
||||
PARAM_CHECKSUM = "md5"
|
||||
PATH_CLS = CloudURLInfo
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
DEFAULT_BLOCKSIZE = 4096
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
def __eq__(self, other):
|
||||
# NOTE: all fsspec MemoryFileSystem instances are equivalent and use a
|
||||
# single global store
|
||||
return isinstance(other, type(self))
|
||||
|
||||
__hash__ = FSSpecWrapper.__hash__
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from fsspec.implementations.memory import MemoryFileSystem as MemFS
|
||||
|
||||
super().__init__(**kwargs)
|
||||
return MemFS(**self.fs_args)
|
||||
|
||||
self.fs = MemFS()
|
||||
|
||||
def exists(self, path_info) -> bool:
|
||||
return self.fs.exists(path_info.fspath)
|
||||
|
||||
def open(self, path_info, mode="r", encoding=None, **kwargs):
|
||||
return self.fs.open(
|
||||
path_info.fspath, mode=mode, encoding=encoding, **kwargs
|
||||
)
|
||||
|
||||
def info(self, path_info):
|
||||
return self.fs.info(path_info.fspath)
|
||||
|
||||
def stat(self, path_info):
|
||||
import os
|
||||
|
||||
info = self.fs.info(path_info.fspath)
|
||||
|
||||
return os.stat_result((0, 0, 0, 0, 0, 0, info["size"], 0, 0, 0))
|
||||
|
||||
def walk_files(self, path_info, **kwargs):
|
||||
raise NotImplementedError
|
||||
def open(self, *args, **kwargs):
|
||||
with super().open(*args, **kwargs) as fobj:
|
||||
fobj.blocksize = self.DEFAULT_BLOCKSIZE
|
||||
return fobj
|
||||
|
||||
+16
-106
@@ -5,127 +5,37 @@ import threading
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.path_info import CloudURLInfo
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .base import BaseFileSystem
|
||||
from .fsspec_wrapper import CallbackMixin, ObjectFSWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OSSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
"""
|
||||
oss2 document:
|
||||
https://www.alibabacloud.com/help/doc-detail/32026.htm
|
||||
|
||||
|
||||
Examples
|
||||
----------
|
||||
$ dvc remote add myremote oss://my-bucket/path
|
||||
Set key id, key secret and endpoint using modify command
|
||||
$ dvc remote modify myremote oss_key_id my-key-id
|
||||
$ dvc remote modify myremote oss_key_secret my-key-secret
|
||||
$ dvc remote modify myremote oss_endpoint endpoint
|
||||
or environment variables
|
||||
$ export OSS_ACCESS_KEY_ID="my-key-id"
|
||||
$ export OSS_ACCESS_KEY_SECRET="my-key-secret"
|
||||
$ export OSS_ENDPOINT="endpoint"
|
||||
"""
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class OSSFileSystem(CallbackMixin, ObjectFSWrapper):
|
||||
scheme = Schemes.OSS
|
||||
PATH_CLS = CloudURLInfo
|
||||
REQUIRES = {"oss2": "oss2"}
|
||||
REQUIRES = {"ossfs": "ossfs"}
|
||||
PARAM_CHECKSUM = "etag"
|
||||
COPY_POLL_SECONDS = 5
|
||||
LIST_OBJECT_PAGE_SIZE = 100
|
||||
DETAIL_FIELDS = frozenset(("etag", "size"))
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
self.endpoint = config.get("oss_endpoint") or os.getenv("OSS_ENDPOINT")
|
||||
|
||||
self.key_id = (
|
||||
config.get("oss_key_id")
|
||||
or os.getenv("OSS_ACCESS_KEY_ID")
|
||||
or "defaultId"
|
||||
def _prepare_credentials(self, **config):
|
||||
login_info = {}
|
||||
login_info["key"] = config.get("oss_key_id") or os.getenv(
|
||||
"OSS_ACCESS_KEY_ID"
|
||||
)
|
||||
|
||||
self.key_secret = (
|
||||
config.get("oss_key_secret")
|
||||
or os.getenv("OSS_ACCESS_KEY_SECRET")
|
||||
or "defaultSecret"
|
||||
login_info["secret"] = config.get("oss_key_secret") or os.getenv(
|
||||
"OSS_ACCESS_KEY_SECRET"
|
||||
)
|
||||
login_info["endpoint"] = config.get("oss_endpoint")
|
||||
return login_info
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def oss_service(self):
|
||||
import oss2
|
||||
def fs(self):
|
||||
from ossfs import OSSFileSystem as _OSSFileSystem
|
||||
|
||||
logger.debug(f"key id: {self.key_id}")
|
||||
logger.debug(f"key secret: {self.key_secret}")
|
||||
|
||||
return oss2.Auth(self.key_id, self.key_secret)
|
||||
|
||||
def _get_bucket(self, bucket):
|
||||
import oss2
|
||||
|
||||
return oss2.Bucket(self.oss_service, self.endpoint, bucket)
|
||||
|
||||
def _generate_download_url(self, path_info, expires=3600):
|
||||
return self._get_bucket(path_info.bucket).sign_url(
|
||||
"GET", path_info.path, expires
|
||||
)
|
||||
|
||||
def exists(self, path_info) -> bool:
|
||||
paths = self._list_paths(path_info)
|
||||
return any(path_info.path == path for path in paths)
|
||||
|
||||
def _list_paths(self, path_info):
|
||||
import oss2
|
||||
|
||||
for blob in oss2.ObjectIterator(
|
||||
self._get_bucket(path_info.bucket), prefix=path_info.path
|
||||
):
|
||||
yield blob.key
|
||||
|
||||
def walk_files(self, path_info, **kwargs):
|
||||
if not kwargs.pop("prefix", False):
|
||||
path_info = path_info / ""
|
||||
for fname in self._list_paths(path_info):
|
||||
if fname.endswith("/"):
|
||||
continue
|
||||
|
||||
yield path_info.replace(path=fname)
|
||||
|
||||
def remove(self, path_info):
|
||||
if path_info.scheme != self.scheme:
|
||||
raise NotImplementedError
|
||||
|
||||
logger.debug(f"Removing oss://{path_info}")
|
||||
self._get_bucket(path_info.bucket).delete_object(path_info.path)
|
||||
|
||||
def _upload_fobj(self, fobj, to_info, **kwargs):
|
||||
self._get_bucket(to_info.bucket).put_object(to_info.path, fobj)
|
||||
|
||||
def _upload(
|
||||
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
|
||||
):
|
||||
with Tqdm(desc=name, disable=no_progress_bar, bytes=True) as pbar:
|
||||
bucket = self._get_bucket(to_info.bucket)
|
||||
bucket.put_object_from_file(
|
||||
to_info.path, from_file, progress_callback=pbar.update_to
|
||||
)
|
||||
|
||||
def _download(
|
||||
self, from_info, to_file, name=None, no_progress_bar=False, **_kwargs
|
||||
):
|
||||
with Tqdm(desc=name, disable=no_progress_bar, bytes=True) as pbar:
|
||||
import oss2
|
||||
|
||||
bucket = self._get_bucket(from_info.bucket)
|
||||
oss2.resumable_download(
|
||||
bucket,
|
||||
from_info.path,
|
||||
to_file,
|
||||
progress_callback=pbar.update_to,
|
||||
)
|
||||
return _OSSFileSystem(**self.fs_args)
|
||||
|
||||
+101
-24
@@ -35,14 +35,29 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
|
||||
scheme = "local"
|
||||
PARAM_CHECKSUM = "md5"
|
||||
PARAM_REPO_URL = "repo_url"
|
||||
PARAM_REPO_ROOT = "repo_root"
|
||||
PARAM_REV = "rev"
|
||||
PARAM_CACHE_DIR = "cache_dir"
|
||||
PARAM_CACHE_TYPES = "cache_types"
|
||||
PARAM_SUBREPOS = "subrepos"
|
||||
|
||||
def __init__(
|
||||
self, repo=None, subrepos=False, repo_factory: RepoFactory = None
|
||||
self,
|
||||
repo: Optional["Repo"] = None,
|
||||
subrepos=False,
|
||||
repo_factory: RepoFactory = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
from dvc.utils.collections import PathStringTrie
|
||||
|
||||
if repo is None:
|
||||
repo, repo_factory = self._repo_from_fs_config(
|
||||
subrepos=subrepos, **kwargs
|
||||
)
|
||||
|
||||
if not repo_factory:
|
||||
from dvc.repo import Repo
|
||||
|
||||
@@ -72,6 +87,67 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
return None
|
||||
return self._main_repo.url
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return {
|
||||
self.PARAM_REPO_URL: self.repo_url,
|
||||
self.PARAM_REPO_ROOT: self.root_dir,
|
||||
self.PARAM_REV: getattr(self._main_repo.fs, "rev", None),
|
||||
self.PARAM_CACHE_DIR: os.path.abspath(
|
||||
self._main_repo.odb.local.cache_dir
|
||||
),
|
||||
self.PARAM_CACHE_TYPES: self._main_repo.odb.local.cache_types,
|
||||
self.PARAM_SUBREPOS: self._traverse_subrepos,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _repo_from_fs_config(
|
||||
cls, **config
|
||||
) -> Tuple["Repo", Optional["RepoFactory"]]:
|
||||
from dvc.external_repo import erepo_factory, external_repo
|
||||
from dvc.repo import Repo
|
||||
|
||||
url = config.get(cls.PARAM_REPO_URL)
|
||||
root = config.get(cls.PARAM_REPO_ROOT)
|
||||
assert url or root
|
||||
|
||||
def _open(*args, **kwargs):
|
||||
# NOTE: if original repo was an erepo (and has a URL),
|
||||
# we cannot use Repo.open() since it will skip erepo
|
||||
# cache/remote setup for local URLs
|
||||
if url is None:
|
||||
return Repo.open(*args, **kwargs)
|
||||
return external_repo(*args, **kwargs)
|
||||
|
||||
cache_dir = config.get(cls.PARAM_CACHE_DIR)
|
||||
cache_config = (
|
||||
{}
|
||||
if not cache_dir
|
||||
else {
|
||||
"cache": {
|
||||
"dir": cache_dir,
|
||||
"type": config.get(cls.PARAM_CACHE_TYPES),
|
||||
}
|
||||
}
|
||||
)
|
||||
repo_kwargs: dict = {
|
||||
"rev": config.get(cls.PARAM_REV),
|
||||
"subrepos": config.get(cls.PARAM_SUBREPOS, False),
|
||||
"uninitialized": True,
|
||||
}
|
||||
factory: Optional["RepoFactory"] = None
|
||||
if url is None:
|
||||
repo_kwargs["config"] = cache_config
|
||||
else:
|
||||
repo_kwargs["cache_dir"] = cache_dir
|
||||
factory = erepo_factory(url, cache_config)
|
||||
|
||||
with _open(
|
||||
url if url else root,
|
||||
**repo_kwargs,
|
||||
) as repo:
|
||||
return repo, factory
|
||||
|
||||
def _get_repo(self, path: str) -> Optional["Repo"]:
|
||||
"""Returns repo that the path falls in, using prefix.
|
||||
|
||||
@@ -136,7 +212,7 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
|
||||
def open(
|
||||
self, path, mode="r", encoding="utf-8", **kwargs
|
||||
): # pylint: disable=arguments-differ
|
||||
): # pylint: disable=arguments-renamed
|
||||
if "b" in mode:
|
||||
encoding = None
|
||||
|
||||
@@ -167,13 +243,13 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
(out,) = meta.outs
|
||||
assert len(meta.outs) == 1
|
||||
if fs.exists(out.path_info):
|
||||
return False
|
||||
for out in meta.outs:
|
||||
if fs.exists(out.path_info):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def isdir(self, path): # pylint: disable=arguments-differ
|
||||
def isdir(self, path): # pylint: disable=arguments-renamed
|
||||
fs, dvc_fs = self._get_fs_pair(path)
|
||||
|
||||
if dvc_fs and dvc_fs.repo.dvcignore.is_ignored_dir(path):
|
||||
@@ -194,17 +270,17 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
(out,) = meta.outs
|
||||
assert len(meta.outs) == 1
|
||||
if fs.exists(out.path_info):
|
||||
return False
|
||||
for out in meta.outs:
|
||||
if fs.exists(out.path_info):
|
||||
return False
|
||||
|
||||
return meta.isdir
|
||||
|
||||
def isdvc(self, path, **kwargs):
|
||||
_, dvc_fs = self._get_fs_pair(path)
|
||||
return dvc_fs is not None and dvc_fs.isdvc(path, **kwargs)
|
||||
|
||||
def isfile(self, path): # pylint: disable=arguments-differ
|
||||
def isfile(self, path): # pylint: disable=arguments-renamed
|
||||
fs, dvc_fs = self._get_fs_pair(path)
|
||||
|
||||
if dvc_fs and dvc_fs.repo.dvcignore.is_ignored_file(path):
|
||||
@@ -377,19 +453,20 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
for fname in files:
|
||||
yield PathInfo(root) / fname
|
||||
|
||||
def _download(
|
||||
self, from_info, to_file, name=None, no_progress_bar=False, **kwargs
|
||||
):
|
||||
import shutil
|
||||
def _download(self, from_info, to_file, **kwargs):
|
||||
fs, dvc_fs = self._get_fs_pair(from_info)
|
||||
try:
|
||||
fs._download( # pylint: disable=protected-access
|
||||
from_info, to_file, **kwargs
|
||||
)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
if not dvc_fs:
|
||||
raise
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
with open(to_file, "wb+") as to_fobj:
|
||||
with Tqdm.wrapattr(
|
||||
to_fobj, "write", desc=name, disable=no_progress_bar
|
||||
) as wrapped:
|
||||
with self.open(from_info, "rb", **kwargs) as from_fobj:
|
||||
shutil.copyfileobj(from_fobj, wrapped)
|
||||
dvc_fs._download( # pylint: disable=protected-access
|
||||
from_info, to_file, **kwargs
|
||||
)
|
||||
|
||||
def metadata(self, path):
|
||||
abspath = os.path.abspath(path)
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import getpass
|
||||
import os.path
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, first, memoize, silent, wrap_prop, wrap_with
|
||||
|
||||
from dvc import prompt
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils.fs import as_atomic
|
||||
|
||||
from .fsspec_wrapper import CallbackMixin, FSSpecWrapper
|
||||
|
||||
_SSH_TIMEOUT = 60 * 30
|
||||
_SSH_CONFIG_FILE = os.path.expanduser(os.path.join("~", ".ssh", "config"))
|
||||
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
@memoize
|
||||
def ask_password(host, user, port):
|
||||
return prompt.password(
|
||||
"Enter a private key passphrase or a password for "
|
||||
"host '{host}' port '{port}' user '{user}'".format(
|
||||
host=host, port=port, user=user
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class SSHFileSystem(CallbackMixin, FSSpecWrapper):
|
||||
scheme = Schemes.SSH
|
||||
REQUIRES = {"sshfs": "sshfs"}
|
||||
|
||||
DEFAULT_PORT = 22
|
||||
PARAM_CHECKSUM = "md5"
|
||||
|
||||
def _with_bucket(self, path):
|
||||
if isinstance(path, self.PATH_CLS):
|
||||
path_str = path.path
|
||||
else:
|
||||
path_str = super()._with_bucket(path)
|
||||
return os.path.normpath(path_str)
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
self.CAN_TRAVERSE = True
|
||||
from sshfs.config import parse_config
|
||||
|
||||
login_info = {}
|
||||
|
||||
try:
|
||||
user_ssh_config = parse_config(host=config["host"])
|
||||
except FileNotFoundError:
|
||||
user_ssh_config = {}
|
||||
|
||||
login_info["host"] = user_ssh_config.get("Hostname", config["host"])
|
||||
|
||||
login_info["username"] = (
|
||||
config.get("user")
|
||||
or config.get("username")
|
||||
or user_ssh_config.get("User")
|
||||
or getpass.getuser()
|
||||
)
|
||||
|
||||
login_info["port"] = (
|
||||
config.get("port")
|
||||
or silent(int)(user_ssh_config.get("Port"))
|
||||
or self.DEFAULT_PORT
|
||||
)
|
||||
|
||||
login_info["password"] = config.get("password")
|
||||
|
||||
if user_ssh_config.get("IdentityFile"):
|
||||
config.setdefault(
|
||||
"keyfile", first(user_ssh_config.get("IdentityFile"))
|
||||
)
|
||||
|
||||
keyfile = config.get("keyfile")
|
||||
if keyfile:
|
||||
login_info["client_keys"] = [os.path.expanduser(keyfile)]
|
||||
login_info["timeout"] = config.get("timeout", _SSH_TIMEOUT)
|
||||
|
||||
# These two settings fine tune the asyncssh to use the
|
||||
# fastest encryption algorithm and disable compression
|
||||
# altogether (since it blocking, it is slowing down
|
||||
# the transfers in a considerable rate, and even for
|
||||
# compressible data it is making it extremely slow).
|
||||
# See: https://github.com/ronf/asyncssh/issues/374
|
||||
login_info["encryption_algs"] = [
|
||||
"[email protected]",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
]
|
||||
login_info["compression_algs"] = None
|
||||
|
||||
login_info["gss_auth"] = config.get("gss_auth", False)
|
||||
login_info["agent_forwarding"] = config.get("agent_forwarding", True)
|
||||
login_info["proxy_command"] = user_ssh_config.get("ProxyCommand")
|
||||
|
||||
if config.get("ask_password") and login_info["password"] is None:
|
||||
login_info["password"] = ask_password(
|
||||
login_info["host"], login_info["username"], login_info["port"]
|
||||
)
|
||||
|
||||
# We are going to automatically add stuff to known_hosts
|
||||
# something like paramiko's AutoAddPolicy()
|
||||
login_info["known_hosts"] = None
|
||||
return login_info
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from sshfs import SSHFileSystem as _SSHFileSystem
|
||||
|
||||
return _SSHFileSystem(**self.fs_args)
|
||||
|
||||
# Ensure that if an interrupt happens during the transfer, we don't
|
||||
# pollute the cache.
|
||||
|
||||
def _upload_fobj(self, fobj, to_info, *args, **kwargs):
|
||||
with as_atomic(self, to_info) as tmp_file:
|
||||
super()._upload_fobj(fobj, tmp_file, *args, **kwargs)
|
||||
|
||||
def _upload(self, from_file, to_info, *args, **kwargs):
|
||||
with as_atomic(self, to_info) as tmp_file:
|
||||
super()._upload(from_file, tmp_file, *args, **kwargs)
|
||||
@@ -1,263 +0,0 @@
|
||||
import getpass
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
from contextlib import closing, contextmanager
|
||||
|
||||
from funcy import first, memoize, silent, wrap_with
|
||||
|
||||
from dvc import prompt
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from ..base import BaseFileSystem
|
||||
from ..pool import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
@memoize
|
||||
def ask_password(host, user, port):
|
||||
return prompt.password(
|
||||
"Enter a private key passphrase or a password for "
|
||||
"host '{host}' port '{port}' user '{user}'".format(
|
||||
host=host, port=port, user=user
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
scheme = Schemes.SSH
|
||||
REQUIRES = {"paramiko": "paramiko"}
|
||||
_JOBS = 4
|
||||
|
||||
PARAM_CHECKSUM = "md5"
|
||||
# At any given time some of the connections will go over network and
|
||||
# paramiko stuff, so we would ideally have it double of server processors.
|
||||
# We use conservative setting of 4 instead to not exhaust max sessions.
|
||||
CHECKSUM_JOBS = 4
|
||||
DEFAULT_CACHE_TYPES = ["copy"]
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
|
||||
DEFAULT_PORT = 22
|
||||
TIMEOUT = 1800
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
user_ssh_config = self._load_user_ssh_config(config["host"])
|
||||
|
||||
self.host = user_ssh_config.get("hostname", config["host"])
|
||||
self.user = (
|
||||
config.get("user")
|
||||
or user_ssh_config.get("user")
|
||||
or getpass.getuser()
|
||||
)
|
||||
self.port = (
|
||||
config.get("port")
|
||||
or self._try_get_ssh_config_port(user_ssh_config)
|
||||
or self.DEFAULT_PORT
|
||||
)
|
||||
|
||||
self.keyfile = config.get(
|
||||
"keyfile"
|
||||
) or self._try_get_ssh_config_keyfile(user_ssh_config)
|
||||
self.timeout = config.get("timeout", self.TIMEOUT)
|
||||
self.password = config.get("password", None)
|
||||
self.ask_password = config.get("ask_password", False)
|
||||
self.gss_auth = config.get("gss_auth", False)
|
||||
proxy_command = user_ssh_config.get("proxycommand", False)
|
||||
if proxy_command:
|
||||
import paramiko
|
||||
|
||||
self.sock = paramiko.ProxyCommand(proxy_command)
|
||||
else:
|
||||
self.sock = None
|
||||
self.allow_agent = config.get("allow_agent", True)
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
from fsspec.implementations.sftp import SFTPFileSystem
|
||||
|
||||
# pylint:disable=protected-access
|
||||
kwargs = SFTPFileSystem._get_kwargs_from_urls(urlpath)
|
||||
if "username" in kwargs:
|
||||
kwargs["user"] = kwargs.pop("username")
|
||||
return kwargs
|
||||
|
||||
@staticmethod
|
||||
def ssh_config_filename():
|
||||
return os.path.expanduser(os.path.join("~", ".ssh", "config"))
|
||||
|
||||
@staticmethod
|
||||
def _load_user_ssh_config(hostname):
|
||||
import paramiko
|
||||
|
||||
user_config_file = SSHFileSystem.ssh_config_filename()
|
||||
user_ssh_config = {}
|
||||
if hostname and os.path.exists(user_config_file):
|
||||
ssh_config = paramiko.SSHConfig()
|
||||
with open(user_config_file) as f:
|
||||
# For whatever reason parsing directly from f is unreliable
|
||||
f_copy = io.StringIO(f.read())
|
||||
ssh_config.parse(f_copy)
|
||||
user_ssh_config = ssh_config.lookup(hostname)
|
||||
return user_ssh_config
|
||||
|
||||
@staticmethod
|
||||
def _try_get_ssh_config_port(user_ssh_config):
|
||||
return silent(int)(user_ssh_config.get("port"))
|
||||
|
||||
@staticmethod
|
||||
def _try_get_ssh_config_keyfile(user_ssh_config):
|
||||
return first(user_ssh_config.get("identityfile") or ())
|
||||
|
||||
def ensure_credentials(self):
|
||||
# NOTE: we use the same password regardless of the server :(
|
||||
if self.ask_password and self.password is None:
|
||||
self.password = ask_password(self.host, self.user, self.port)
|
||||
|
||||
def ssh(self):
|
||||
self.ensure_credentials()
|
||||
|
||||
from .connection import SSHConnection
|
||||
|
||||
return get_connection(
|
||||
SSHConnection,
|
||||
self.host,
|
||||
username=self.user,
|
||||
port=self.port,
|
||||
key_filename=self.keyfile,
|
||||
timeout=self.timeout,
|
||||
password=self.password,
|
||||
gss_auth=self.gss_auth,
|
||||
sock=self.sock,
|
||||
allow_agent=self.allow_agent,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def open(self, path_info, mode="r", encoding=None, **kwargs):
|
||||
assert mode in {"r", "rt", "rb", "wb"}
|
||||
|
||||
with self.ssh() as ssh, closing(
|
||||
ssh.sftp.open(path_info.path, mode)
|
||||
) as fd:
|
||||
if "b" in mode:
|
||||
yield fd
|
||||
else:
|
||||
yield io.TextIOWrapper(fd, encoding=encoding)
|
||||
|
||||
def exists(self, path_info) -> bool:
|
||||
with self.ssh() as ssh:
|
||||
return ssh.exists(path_info.path)
|
||||
|
||||
def isdir(self, path_info):
|
||||
with self.ssh() as ssh:
|
||||
return ssh.isdir(path_info.path)
|
||||
|
||||
def isfile(self, path_info):
|
||||
with self.ssh() as ssh:
|
||||
return ssh.isfile(path_info.path)
|
||||
|
||||
def walk_files(self, path_info, **kwargs):
|
||||
with self.ssh() as ssh:
|
||||
for fname in ssh.walk_files(path_info.path):
|
||||
yield path_info.replace(path=fname)
|
||||
|
||||
def remove(self, path_info):
|
||||
if path_info.scheme != self.scheme:
|
||||
raise NotImplementedError
|
||||
|
||||
with self.ssh() as ssh:
|
||||
ssh.remove(path_info.path)
|
||||
|
||||
def makedirs(self, path_info):
|
||||
with self.ssh() as ssh:
|
||||
ssh.makedirs(path_info.path)
|
||||
|
||||
def move(self, from_info, to_info):
|
||||
if from_info.scheme != self.scheme or to_info.scheme != self.scheme:
|
||||
raise NotImplementedError
|
||||
|
||||
with self.ssh() as ssh:
|
||||
ssh.move(from_info.path, to_info.path)
|
||||
|
||||
def copy(self, from_info, to_info):
|
||||
if not from_info.scheme == to_info.scheme == self.scheme:
|
||||
raise NotImplementedError
|
||||
|
||||
with self.ssh() as ssh:
|
||||
ssh.atomic_copy(from_info.path, to_info.path)
|
||||
|
||||
def symlink(self, from_info, to_info):
|
||||
if not from_info.scheme == to_info.scheme == self.scheme:
|
||||
raise NotImplementedError
|
||||
|
||||
with self.ssh() as ssh:
|
||||
ssh.symlink(from_info.path, to_info.path)
|
||||
|
||||
def hardlink(self, from_info, to_info):
|
||||
if not from_info.scheme == to_info.scheme == self.scheme:
|
||||
raise NotImplementedError
|
||||
|
||||
# See dvc/remote/local/__init__.py - hardlink()
|
||||
if self.getsize(from_info) == 0:
|
||||
|
||||
with self.ssh() as ssh:
|
||||
ssh.sftp.open(to_info.path, "w").close()
|
||||
|
||||
logger.debug(
|
||||
"Created empty file: {src} -> {dest}".format(
|
||||
src=str(from_info), dest=str(to_info)
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
with self.ssh() as ssh:
|
||||
ssh.hardlink(from_info.path, to_info.path)
|
||||
|
||||
def reflink(self, from_info, to_info):
|
||||
if from_info.scheme != self.scheme or to_info.scheme != self.scheme:
|
||||
raise NotImplementedError
|
||||
|
||||
with self.ssh() as ssh:
|
||||
ssh.reflink(from_info.path, to_info.path)
|
||||
|
||||
def md5(self, path_info):
|
||||
with self.ssh() as ssh:
|
||||
return HashInfo(
|
||||
"md5",
|
||||
ssh.md5(path_info.path),
|
||||
size=ssh.getsize(path_info.path),
|
||||
)
|
||||
|
||||
def info(self, path_info):
|
||||
with self.ssh() as ssh:
|
||||
return ssh.info(path_info.path)
|
||||
|
||||
def _upload_fobj(self, fobj, to_info, **kwargs):
|
||||
self.makedirs(to_info.parent)
|
||||
with self.open(to_info, mode="wb") as fdest:
|
||||
shutil.copyfileobj(fobj, fdest)
|
||||
|
||||
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
|
||||
with self.ssh() as ssh:
|
||||
ssh.download(
|
||||
from_info.path,
|
||||
to_file,
|
||||
progress_title=name,
|
||||
no_progress_bar=no_progress_bar,
|
||||
)
|
||||
|
||||
def _upload(
|
||||
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
|
||||
):
|
||||
with self.ssh() as ssh:
|
||||
ssh.upload(
|
||||
from_file,
|
||||
to_info.path,
|
||||
progress_title=name,
|
||||
no_progress_bar=no_progress_bar,
|
||||
)
|
||||
@@ -1,356 +0,0 @@
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import shlex
|
||||
import stat
|
||||
from contextlib import suppress
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.utils import tmp_fname
|
||||
|
||||
from ..base import RemoteCmdError
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError:
|
||||
paramiko = None # type: ignore
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sizeof_fmt(num, suffix="B"):
|
||||
"""Convert number of bytes to human-readable string"""
|
||||
for unit in ["", "K", "M", "G", "T", "P", "E", "Z"]:
|
||||
if abs(num) < 1024.0:
|
||||
return f"{num:3.1f}{unit}{suffix}"
|
||||
num /= 1024.0
|
||||
return "{:.1f}{}{}".format(num, "Y", suffix)
|
||||
|
||||
|
||||
class SSHConnection:
|
||||
def __init__(self, host, *args, **kwargs):
|
||||
logger.debug(
|
||||
"Establishing ssh connection with '{host}' "
|
||||
"through port '{port}' as user '{username}'".format(
|
||||
host=host, **kwargs
|
||||
)
|
||||
)
|
||||
|
||||
kwargs.setdefault("timeout", 1800)
|
||||
self.timeout = kwargs["timeout"]
|
||||
|
||||
self._ssh = paramiko.SSHClient()
|
||||
|
||||
# Explicitly disable paramiko logger. Due to how paramiko dynamically
|
||||
# loads loggers, it is not disabled by DVC disable_other_loggers().
|
||||
# See https://github.com/iterative/dvc/issues/3482
|
||||
self._ssh.set_log_channel("dvc.paramiko")
|
||||
logging.getLogger("dvc.paramiko").disabled = True
|
||||
logging.getLogger("dvc.paramiko.sftp").disabled = True
|
||||
|
||||
self._ssh.load_system_host_keys()
|
||||
self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
self._ssh.connect(host, *args, **kwargs)
|
||||
self._ssh.get_transport().set_keepalive(10)
|
||||
self._sftp_channels = []
|
||||
|
||||
@property
|
||||
def sftp(self):
|
||||
if not self._sftp_channels:
|
||||
self._sftp_channels = [self._ssh.open_sftp()]
|
||||
return self._sftp_channels[0]
|
||||
|
||||
def close(self):
|
||||
for sftp in self._sftp_channels:
|
||||
sftp.close()
|
||||
self._ssh.close()
|
||||
|
||||
def st_mode(self, path):
|
||||
lstat = None
|
||||
with suppress(FileNotFoundError):
|
||||
lstat = self.sftp.lstat(path)
|
||||
|
||||
return lstat.st_mode if lstat else 0
|
||||
|
||||
def info(self, path):
|
||||
st = self.sftp.stat(path)
|
||||
return {
|
||||
"size": st.st_size,
|
||||
"type": "directory" if stat.S_ISDIR(st.st_mode) else "file",
|
||||
}
|
||||
|
||||
def getsize(self, path):
|
||||
return self.info(path)["size"]
|
||||
|
||||
def exists(self, path):
|
||||
return bool(self.st_mode(path))
|
||||
|
||||
def isdir(self, path):
|
||||
return stat.S_ISDIR(self.st_mode(path))
|
||||
|
||||
def isfile(self, path):
|
||||
return stat.S_ISREG(self.st_mode(path))
|
||||
|
||||
def islink(self, path):
|
||||
return stat.S_ISLNK(self.st_mode(path))
|
||||
|
||||
def makedirs(self, path):
|
||||
# Single stat call will say whether this is a dir, a file or a link
|
||||
st_mode = self.st_mode(path)
|
||||
|
||||
if stat.S_ISDIR(st_mode):
|
||||
return
|
||||
|
||||
if stat.S_ISREG(st_mode) or stat.S_ISLNK(st_mode):
|
||||
raise DvcException(
|
||||
f"a file with the same name '{path}' already exists"
|
||||
)
|
||||
|
||||
head, tail = posixpath.split(path)
|
||||
|
||||
if head:
|
||||
self.makedirs(head)
|
||||
|
||||
if tail:
|
||||
try:
|
||||
self.sftp.mkdir(path)
|
||||
except OSError as exc:
|
||||
# Since paramiko errors are very vague we need to recheck
|
||||
# whether it's because path already exists or something else
|
||||
if exc.errno == errno.EACCES or not self.exists(path):
|
||||
raise DvcException(
|
||||
f"unable to create remote directory '{path}'"
|
||||
) from exc
|
||||
|
||||
def walk(self, directory, topdown=True):
|
||||
# NOTE: original os.walk() implementation [1] with default options was
|
||||
# used as a template.
|
||||
#
|
||||
# [1] https://github.com/python/cpython/blob/master/Lib/os.py
|
||||
try:
|
||||
dir_entries = self.sftp.listdir_attr(directory)
|
||||
except OSError as exc:
|
||||
raise DvcException(
|
||||
"couldn't get the '{}' remote directory files list".format(
|
||||
directory
|
||||
)
|
||||
) from exc
|
||||
|
||||
dirs = []
|
||||
nondirs = []
|
||||
for entry in dir_entries:
|
||||
name = entry.filename
|
||||
if stat.S_ISDIR(entry.st_mode):
|
||||
dirs.append(name)
|
||||
else:
|
||||
nondirs.append(name)
|
||||
|
||||
if topdown:
|
||||
yield directory, dirs, nondirs
|
||||
|
||||
for dname in dirs:
|
||||
newpath = posixpath.join(directory, dname)
|
||||
yield from self.walk(newpath, topdown=topdown)
|
||||
|
||||
if not topdown:
|
||||
yield directory, dirs, nondirs
|
||||
|
||||
def walk_files(self, directory):
|
||||
for root, _, files in self.walk(directory):
|
||||
for fname in files:
|
||||
yield posixpath.join(root, fname)
|
||||
|
||||
def _remove_file(self, path):
|
||||
with suppress(FileNotFoundError):
|
||||
self.sftp.remove(path)
|
||||
|
||||
def _remove_dir(self, path):
|
||||
for root, dirs, files in self.walk(path, topdown=False):
|
||||
for fname in files:
|
||||
with suppress(FileNotFoundError):
|
||||
self._remove_file(posixpath.join(root, fname))
|
||||
|
||||
for dname in dirs:
|
||||
with suppress(FileNotFoundError):
|
||||
self.sftp.rmdir(posixpath.join(root, dname))
|
||||
|
||||
with suppress(FileNotFoundError):
|
||||
self.sftp.rmdir(path)
|
||||
|
||||
def remove(self, path):
|
||||
if self.isdir(path):
|
||||
self._remove_dir(path)
|
||||
else:
|
||||
self._remove_file(path)
|
||||
|
||||
def download(self, src, dest, no_progress_bar=False, progress_title=None):
|
||||
with Tqdm(
|
||||
desc=progress_title or os.path.basename(src),
|
||||
disable=no_progress_bar,
|
||||
bytes=True,
|
||||
total=self.getsize(src),
|
||||
) as pbar:
|
||||
self.sftp.get(src, dest, callback=pbar.update_to)
|
||||
|
||||
def move(self, src, dst):
|
||||
"""Rename src to dst, if it is not possible (in case src and dst are
|
||||
on different filesystems) and actual physical copying of data is
|
||||
happening.
|
||||
"""
|
||||
self.makedirs(posixpath.dirname(dst))
|
||||
|
||||
try:
|
||||
self.sftp.rename(src, dst)
|
||||
except OSError:
|
||||
self.atomic_copy(src, dst)
|
||||
self.remove(src)
|
||||
|
||||
def atomic_copy(self, src, dst):
|
||||
tmp = tmp_fname(dst)
|
||||
|
||||
try:
|
||||
self.copy(src, tmp)
|
||||
self.sftp.rename(tmp, dst)
|
||||
finally:
|
||||
self.remove(tmp)
|
||||
|
||||
def upload(self, src, dest, no_progress_bar=False, progress_title=None):
|
||||
self.makedirs(posixpath.dirname(dest))
|
||||
tmp_file = tmp_fname(dest)
|
||||
if not progress_title:
|
||||
progress_title = posixpath.basename(dest)
|
||||
|
||||
with Tqdm(
|
||||
desc=progress_title,
|
||||
disable=no_progress_bar,
|
||||
bytes=True,
|
||||
total=os.path.getsize(src),
|
||||
) as pbar:
|
||||
self.sftp.put(src, tmp_file, callback=pbar.update_to)
|
||||
|
||||
self.sftp.rename(tmp_file, dest)
|
||||
|
||||
def execute(self, cmd):
|
||||
stdin, stdout, stderr = self._ssh.exec_command(cmd)
|
||||
channel = stdout.channel
|
||||
|
||||
stdin.close()
|
||||
channel.shutdown_write()
|
||||
|
||||
stdout_chunks = []
|
||||
stderr_chunks = []
|
||||
while (
|
||||
not channel.closed
|
||||
or channel.recv_ready()
|
||||
or channel.recv_stderr_ready()
|
||||
):
|
||||
import selectors
|
||||
|
||||
selector = selectors.DefaultSelector()
|
||||
selector.register(stdout.channel, selectors.EVENT_READ)
|
||||
|
||||
got_chunk = False
|
||||
events = selector.select(self.timeout)
|
||||
for key, _ in events:
|
||||
c = key.fileobj
|
||||
if c.recv_ready():
|
||||
stdout_chunks.append(stdout.channel.recv(len(c.in_buffer)))
|
||||
got_chunk = True
|
||||
|
||||
if c.recv_stderr_ready():
|
||||
stderr_len = len(c.in_stderr_buffer)
|
||||
s = stderr.channel.recv_stderr(stderr_len)
|
||||
stderr_chunks.append(s)
|
||||
got_chunk = True
|
||||
|
||||
if (
|
||||
not got_chunk
|
||||
and stdout.channel.exit_status_ready()
|
||||
and not stderr.channel.recv_stderr_ready()
|
||||
and not stdout.channel.recv_ready()
|
||||
):
|
||||
stdout.channel.shutdown_read()
|
||||
stdout.channel.close()
|
||||
break
|
||||
|
||||
stdout.close()
|
||||
stderr.close()
|
||||
|
||||
ret = stdout.channel.recv_exit_status()
|
||||
if ret != 0:
|
||||
err = b"".join(stderr_chunks).decode("utf-8")
|
||||
raise RemoteCmdError("ssh", cmd, ret, err)
|
||||
|
||||
return b"".join(stdout_chunks).decode("utf-8")
|
||||
|
||||
@cached_property
|
||||
def uname(self):
|
||||
return self.execute("uname").strip()
|
||||
|
||||
def md5(self, path):
|
||||
"""
|
||||
Use different md5 commands depending on the OS:
|
||||
|
||||
- Darwin's `md5` returns BSD-style checksums by default
|
||||
- Linux's `md5sum` needs the `--tag` flag for a similar output
|
||||
|
||||
Example:
|
||||
MD5 (foo.txt) = f3d220a856b52aabbf294351e8a24300
|
||||
"""
|
||||
path = shlex.quote(path)
|
||||
if self.uname == "Linux":
|
||||
md5 = self.execute("md5sum " + path).split()[0]
|
||||
elif self.uname == "Darwin":
|
||||
md5 = self.execute("md5 " + path).split()[-1]
|
||||
else:
|
||||
raise DvcException(
|
||||
f"'{self.uname}' is not supported as a SSH remote"
|
||||
)
|
||||
|
||||
assert len(md5) == 32
|
||||
return md5
|
||||
|
||||
def copy(self, src, dest):
|
||||
dest = shlex.quote(dest)
|
||||
src = shlex.quote(src)
|
||||
self.execute(f"cp {src} {dest}")
|
||||
|
||||
def open_max_sftp_channels(self):
|
||||
# If there are more than 1 it means we've already opened max amount
|
||||
if len(self._sftp_channels) <= 1:
|
||||
while True:
|
||||
try:
|
||||
self._sftp_channels.append(self._ssh.open_sftp())
|
||||
except paramiko.ssh_exception.ChannelException:
|
||||
if not self._sftp_channels:
|
||||
raise
|
||||
break
|
||||
return self._sftp_channels
|
||||
|
||||
def open(self, *args, **kwargs):
|
||||
return self.sftp.open(*args, **kwargs)
|
||||
|
||||
def symlink(self, src, dest):
|
||||
self.sftp.symlink(src, dest)
|
||||
|
||||
def reflink(self, src, dest):
|
||||
dest = shlex.quote(dest)
|
||||
src = shlex.quote(src)
|
||||
if self.uname == "Linux":
|
||||
return self.execute(f"cp --reflink {src} {dest}")
|
||||
|
||||
if self.uname == "Darwin":
|
||||
return self.execute(f"cp -c {src} {dest}")
|
||||
|
||||
raise DvcException(f"'{self.uname}' is not supported as a SSH remote")
|
||||
|
||||
def hardlink(self, src, dest):
|
||||
dest = shlex.quote(dest)
|
||||
src = shlex.quote(src)
|
||||
self.execute(f"ln {src} {dest}")
|
||||
@@ -81,10 +81,6 @@ class WebDAVFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
|
||||
fobj, rpath, overwrite=True, size=size
|
||||
)
|
||||
|
||||
def makedirs(self, path_info):
|
||||
path = self.translate_path_info(path_info)
|
||||
return self.fs.makedirs(path, exist_ok=True)
|
||||
|
||||
@lru_cache(512)
|
||||
def translate_path_info(self, path):
|
||||
if isinstance(path, self.PATH_CLS):
|
||||
@@ -93,9 +89,6 @@ class WebDAVFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
|
||||
|
||||
_with_bucket = translate_path_info
|
||||
|
||||
def _strip_bucket(self, entry):
|
||||
return entry
|
||||
|
||||
|
||||
class WebDAVSFileSystem(WebDAVFileSystem): # pylint:disable=abstract-method
|
||||
scheme = Schemes.WEBDAVS
|
||||
|
||||
@@ -14,6 +14,7 @@ class HashInfo:
|
||||
value: Optional[str]
|
||||
size: Optional[int] = field(default=None, compare=False)
|
||||
nfiles: Optional[int] = field(default=None, compare=False)
|
||||
obj_name: Optional[str] = field(default=None, compare=False)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.value)
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@ class DvcIgnorePatterns(DvcIgnore):
|
||||
dirname = os.path.normpath(os.path.dirname(path))
|
||||
with fs.open(path, encoding="utf-8") as fobj:
|
||||
path_spec_lines = [
|
||||
PatternInfo(line, "{}:{}:{}".format(name, line_no + 1, line))
|
||||
PatternInfo(line, f"{name}:{line_no + 1}:{line}")
|
||||
for line_no, line in enumerate(
|
||||
map(str.strip, fobj.readlines())
|
||||
)
|
||||
@@ -166,7 +166,7 @@ class DvcIgnoreFilter:
|
||||
".hg/",
|
||||
".git/",
|
||||
".git",
|
||||
"{}/".format(Repo.DVC_DIR),
|
||||
f"{Repo.DVC_DIR}/",
|
||||
]
|
||||
|
||||
self.fs = fs
|
||||
|
||||
+2
-4
@@ -42,9 +42,7 @@ def get_dvc_info():
|
||||
# later decides to enable shared cache mode with
|
||||
# `dvc config cache.shared group`.
|
||||
if os.path.exists(repo.odb.local.cache_dir):
|
||||
info.append(
|
||||
"Cache types: {}".format(_get_linktype_support_info(repo))
|
||||
)
|
||||
info.append(f"Cache types: {_get_linktype_support_info(repo)}")
|
||||
fs_type = get_fs_type(repo.odb.local.cache_dir)
|
||||
info.append(f"Cache directory: {fs_type}")
|
||||
else:
|
||||
@@ -56,7 +54,7 @@ def get_dvc_info():
|
||||
root_directory = repo.root_dir
|
||||
fs_root = get_fs_type(os.path.abspath(root_directory))
|
||||
info.append(f"Workspace directory: {fs_root}")
|
||||
info.append("Repo: {}".format(_get_dvc_repo_info(repo)))
|
||||
info.append(f"Repo: {_get_dvc_repo_info(repo)}")
|
||||
except NotDvcRepoError:
|
||||
pass
|
||||
except SCMError:
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@
|
||||
|
||||
|
||||
TEXT_CHARS = bytes(range(32, 127)) + b"\n\r\t\f\b"
|
||||
DEFAULT_CHUNK_SIZE = 512
|
||||
|
||||
|
||||
def istextblock(block):
|
||||
@@ -22,7 +23,7 @@ def istextblock(block):
|
||||
return float(len(nontext)) / len(block) <= 0.30
|
||||
|
||||
|
||||
def istextfile(fname, fs, blocksize=512):
|
||||
def istextfile(fname, fs, blocksize=DEFAULT_CHUNK_SIZE):
|
||||
"""Uses heuristics to guess whether the given file is text or binary,
|
||||
by reading a single block of bytes from the file.
|
||||
If more than 30% of the chars in the block are non-text, or there
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ def addLoggingLevel(levelName, levelNum, methodName=None):
|
||||
|
||||
class LoggingException(Exception):
|
||||
def __init__(self, record):
|
||||
msg = "failed to log {}".format(str(record))
|
||||
msg = f"failed to log {str(record)}"
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
|
||||
+16
-37
@@ -1,55 +1,34 @@
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
from typing import TYPE_CHECKING, Iterator, Union
|
||||
|
||||
from .tree import Tree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
|
||||
from .db.base import ObjectDB
|
||||
from .file import HashFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def save(
|
||||
odb: "ObjectDB",
|
||||
obj: "HashFile",
|
||||
jobs: Optional[int] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if isinstance(obj, Tree):
|
||||
with ThreadPoolExecutor(max_workers=jobs) as executor:
|
||||
for future in Tqdm(
|
||||
as_completed(
|
||||
executor.submit(
|
||||
odb.add,
|
||||
entry.path_info,
|
||||
entry.fs,
|
||||
entry.hash_info,
|
||||
**kwargs,
|
||||
)
|
||||
for _, entry in obj
|
||||
),
|
||||
desc="Saving files",
|
||||
total=len(obj),
|
||||
unit="file",
|
||||
):
|
||||
future.result()
|
||||
|
||||
odb.add(obj.path_info, obj.fs, obj.hash_info, **kwargs)
|
||||
|
||||
|
||||
def check(odb, obj):
|
||||
odb.check(obj.hash_info)
|
||||
|
||||
def check(odb: "ObjectDB", obj: "HashFile", **kwargs):
|
||||
if isinstance(obj, Tree):
|
||||
for _, entry in obj:
|
||||
odb.check(entry.hash_info)
|
||||
odb.check(entry.hash_info, **kwargs)
|
||||
|
||||
odb.check(obj.hash_info, **kwargs)
|
||||
|
||||
|
||||
def load(odb, hash_info):
|
||||
def load(odb: "ObjectDB", hash_info: "HashInfo") -> "HashFile":
|
||||
if hash_info.isdir:
|
||||
return Tree.load(odb, hash_info)
|
||||
return odb.get(hash_info)
|
||||
|
||||
|
||||
def iterobjs(
|
||||
obj: Union["Tree", "HashFile"]
|
||||
) -> Iterator[Union["Tree", "HashFile"]]:
|
||||
if isinstance(obj, Tree):
|
||||
yield from (entry_obj for _, entry_obj in obj)
|
||||
yield obj
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .index import ObjectDBIndexBase
|
||||
|
||||
|
||||
def get_odb(fs, path_info, **config):
|
||||
from .base import ObjectDB
|
||||
from .gdrive import GDriveObjectDB
|
||||
from .local import LocalObjectDB
|
||||
from .ssh import SSHObjectDB
|
||||
from .oss import OSSObjectDB
|
||||
|
||||
if fs.scheme == Schemes.LOCAL:
|
||||
return LocalObjectDB(fs, path_info, **config)
|
||||
|
||||
if fs.scheme == Schemes.SSH:
|
||||
return SSHObjectDB(fs, path_info, **config)
|
||||
|
||||
if fs.scheme == Schemes.GDRIVE:
|
||||
return GDriveObjectDB(fs, path_info, **config)
|
||||
|
||||
if fs.scheme == Schemes.OSS:
|
||||
return OSSObjectDB(fs, path_info, **config)
|
||||
|
||||
return ObjectDB(fs, path_info, **config)
|
||||
|
||||
|
||||
@@ -26,9 +31,23 @@ def _get_odb(repo, settings):
|
||||
return None
|
||||
|
||||
cls, config, path_info = get_cloud_fs(repo, **settings)
|
||||
config["tmp_dir"] = repo.tmp_dir
|
||||
return get_odb(cls(**config), path_info, state=repo.state, **config)
|
||||
|
||||
|
||||
def get_index(odb) -> "ObjectDBIndexBase":
|
||||
import hashlib
|
||||
|
||||
from .index import ObjectDBIndex, ObjectDBIndexNoop
|
||||
|
||||
cls = ObjectDBIndex if odb.tmp_dir else ObjectDBIndexNoop
|
||||
return cls(
|
||||
odb.tmp_dir,
|
||||
hashlib.sha256(odb.path_info.url.encode("utf-8")).hexdigest(),
|
||||
odb.fs.CHECKSUM_DIR_SUFFIX,
|
||||
)
|
||||
|
||||
|
||||
class ODBManager:
|
||||
CACHE_DIR = "cache"
|
||||
CLOUD_SCHEMES = [
|
||||
|
||||
+99
-40
@@ -2,14 +2,20 @@ import itertools
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import suppress
|
||||
from copy import copy
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
from dvc.objects.errors import ObjectDBPermissionError, ObjectFormatError
|
||||
from dvc.objects.file import HashFile
|
||||
from dvc.objects.tree import Tree
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.base import BaseFileSystem
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.types import AnyPath, DvcPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -19,7 +25,7 @@ class ObjectDB:
|
||||
DEFAULT_CACHE_TYPES = ["copy"]
|
||||
CACHE_MODE: Optional[int] = None
|
||||
|
||||
def __init__(self, fs, path_info, **config):
|
||||
def __init__(self, fs: "BaseFileSystem", path_info: "AnyPath", **config):
|
||||
from dvc.state import StateNoop
|
||||
|
||||
self.fs = fs
|
||||
@@ -30,6 +36,7 @@ class ObjectDB:
|
||||
self.cache_type_confirmed = False
|
||||
self.slow_link_warning = config.get("slow_link_warning", True)
|
||||
self.tmp_dir = config.get("tmp_dir")
|
||||
self.read_only = config.get("read_only", False)
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
@@ -39,21 +46,29 @@ class ObjectDB:
|
||||
"type": self.cache_types,
|
||||
"slow_link_warning": self.slow_link_warning,
|
||||
"tmp_dir": self.tmp_dir,
|
||||
"read_only": self.read_only,
|
||||
}
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.fs == other.fs and self.path_info == other.path_info
|
||||
return (
|
||||
self.fs == other.fs
|
||||
and self.path_info == other.path_info
|
||||
and self.read_only == other.read_only
|
||||
)
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.fs.scheme, self.path_info))
|
||||
|
||||
def exists(self, hash_info: "HashInfo"):
|
||||
return self.fs.exists(self.hash_to_path_info(hash_info.value))
|
||||
|
||||
def move(self, from_info, to_info):
|
||||
self.fs.move(from_info, to_info)
|
||||
|
||||
def makedirs(self, path_info):
|
||||
self.fs.makedirs(path_info)
|
||||
|
||||
def get(self, hash_info):
|
||||
def get(self, hash_info: "HashInfo"):
|
||||
"""get raw object"""
|
||||
return HashFile(
|
||||
# Prefer string path over PathInfo when possible due to performance
|
||||
@@ -62,23 +77,28 @@ class ObjectDB:
|
||||
hash_info,
|
||||
)
|
||||
|
||||
def add(self, path_info, fs, hash_info, move=True, **kwargs):
|
||||
try:
|
||||
self.check(hash_info, check_hash=self.verify)
|
||||
return
|
||||
except (ObjectFormatError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
cache_info = self.hash_to_path_info(hash_info.value)
|
||||
# using our makedirs to create dirs with proper permissions
|
||||
self.makedirs(cache_info.parent)
|
||||
use_move = isinstance(fs, type(self.fs)) and move
|
||||
def _add_file(
|
||||
self,
|
||||
from_fs: "BaseFileSystem",
|
||||
from_info: "AnyPath",
|
||||
to_info: "DvcPath",
|
||||
_hash_info: "HashInfo",
|
||||
move: bool = False,
|
||||
):
|
||||
self.makedirs(to_info.parent)
|
||||
use_move = isinstance(from_fs, type(self.fs)) and move
|
||||
try:
|
||||
if use_move:
|
||||
self.fs.move(path_info, cache_info)
|
||||
self.fs.move(from_info, to_info)
|
||||
elif isinstance(from_fs, LocalFileSystem):
|
||||
if not isinstance(from_info, from_fs.PATH_CLS):
|
||||
from_info = from_fs.PATH_CLS(from_info)
|
||||
self.fs.upload(from_info, to_info)
|
||||
elif isinstance(self.fs, LocalFileSystem):
|
||||
from_fs.download_file(from_info, to_info)
|
||||
else:
|
||||
with fs.open(path_info, mode="rb") as fobj:
|
||||
self.fs.upload_fobj(fobj, cache_info)
|
||||
with from_fs.open(from_info, mode="rb") as fobj:
|
||||
self.fs.upload_fobj(fobj, to_info)
|
||||
except OSError as exc:
|
||||
# If the target file already exists, we are going to simply
|
||||
# ignore the exception (#4992).
|
||||
@@ -92,20 +112,48 @@ class ObjectDB:
|
||||
and exc.__context__
|
||||
and isinstance(exc.__context__, FileExistsError)
|
||||
):
|
||||
logger.debug("'%s' file already exists, skipping", path_info)
|
||||
logger.debug("'%s' file already exists, skipping", to_info)
|
||||
if use_move:
|
||||
fs.remove(path_info)
|
||||
from_fs.remove(from_info)
|
||||
else:
|
||||
raise
|
||||
|
||||
self.protect(cache_info)
|
||||
self.state.save(cache_info, self.fs, hash_info)
|
||||
def add(
|
||||
self,
|
||||
path_info: "AnyPath",
|
||||
fs: "BaseFileSystem",
|
||||
hash_info: "HashInfo",
|
||||
move: bool = True,
|
||||
verify: Optional[bool] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if self.read_only:
|
||||
raise ObjectDBPermissionError("Cannot add to read-only ODB")
|
||||
|
||||
if verify is None:
|
||||
verify = self.verify
|
||||
try:
|
||||
self.check(hash_info, check_hash=verify)
|
||||
return
|
||||
except (ObjectFormatError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
cache_info = self.hash_to_path_info(hash_info.value)
|
||||
self._add_file(fs, path_info, cache_info, hash_info, move)
|
||||
|
||||
try:
|
||||
if verify:
|
||||
self.check(hash_info, check_hash=True)
|
||||
self.protect(cache_info)
|
||||
self.state.save(cache_info, self.fs, hash_info)
|
||||
except (ObjectFormatError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
callback = kwargs.get("download_callback")
|
||||
if callback:
|
||||
callback(1)
|
||||
|
||||
def hash_to_path_info(self, hash_):
|
||||
def hash_to_path_info(self, hash_) -> "DvcPath":
|
||||
return self.path_info / hash_[0:2] / hash_[2:]
|
||||
|
||||
# Override to return path as a string instead of PathInfo for clouds
|
||||
@@ -125,7 +173,11 @@ class ObjectDB:
|
||||
def set_exec(self, path_info): # pylint: disable=unused-argument
|
||||
pass
|
||||
|
||||
def check(self, hash_info, check_hash=True):
|
||||
def check(
|
||||
self,
|
||||
hash_info: "HashInfo",
|
||||
check_hash: bool = True,
|
||||
):
|
||||
"""Compare the given hash with the (corresponding) actual one if
|
||||
check_hash is specified, or just verify the existence of the cache
|
||||
files on the filesystem.
|
||||
@@ -142,7 +194,7 @@ class ObjectDB:
|
||||
|
||||
obj = self.get(hash_info)
|
||||
if self.is_protected(obj.path_info):
|
||||
logger.trace(
|
||||
logger.trace( # type: ignore[attr-defined]
|
||||
"Assuming '%s' is unchanged since it is read-only",
|
||||
obj.path_info,
|
||||
)
|
||||
@@ -152,12 +204,14 @@ class ObjectDB:
|
||||
obj.check(self, check_hash=check_hash)
|
||||
except ObjectFormatError:
|
||||
logger.warning("corrupted cache file '%s'.", obj.path_info)
|
||||
self.fs.remove(obj.path_info)
|
||||
with suppress(FileNotFoundError):
|
||||
self.fs.remove(obj.path_info)
|
||||
raise
|
||||
|
||||
# making cache file read-only so we don't need to check it
|
||||
# next time
|
||||
self.protect(obj.path_info)
|
||||
if check_hash:
|
||||
# making cache file read-only so we don't need to check it
|
||||
# next time
|
||||
self.protect(obj.path_info)
|
||||
|
||||
def _list_paths(self, prefix=None, progress_callback=None):
|
||||
if prefix:
|
||||
@@ -334,13 +388,20 @@ class ObjectDB:
|
||||
def _remove_unpacked_dir(self, hash_):
|
||||
pass
|
||||
|
||||
def gc(self, used, jobs=None):
|
||||
def gc(self, used, jobs=None, cache_odb=None, shallow=True):
|
||||
from ..tree import Tree
|
||||
|
||||
if self.read_only:
|
||||
raise ObjectDBPermissionError("Cannot gc read-only ODB")
|
||||
if not cache_odb:
|
||||
cache_odb = self
|
||||
used_hashes = set()
|
||||
for obj in used:
|
||||
used_hashes.add(obj.hash_info.value)
|
||||
if isinstance(obj, Tree):
|
||||
for hash_info in used:
|
||||
used_hashes.add(hash_info.value)
|
||||
if hash_info.isdir and not shallow:
|
||||
tree = Tree.load(cache_odb, hash_info)
|
||||
used_hashes.update(
|
||||
entry_obj.hash_info.value for _, entry_obj in obj
|
||||
entry_obj.hash_info.value for _, entry_obj in tree
|
||||
)
|
||||
|
||||
removed = False
|
||||
@@ -366,9 +427,7 @@ class ObjectDB:
|
||||
"""Return list of the specified hashes which exist in this fs.
|
||||
Hashes will be queried individually.
|
||||
"""
|
||||
logger.debug(
|
||||
"Querying {} hashes via object_exists".format(len(hashes))
|
||||
)
|
||||
logger.debug(f"Querying {len(hashes)} hashes via object_exists")
|
||||
with Tqdm(
|
||||
desc="Querying "
|
||||
+ ("cache in " + name if name else "remote cache"),
|
||||
@@ -463,7 +522,7 @@ class ObjectDB:
|
||||
hashes - remote_hashes, jobs, name
|
||||
)
|
||||
|
||||
logger.debug("Querying '{}' hashes via traverse".format(len(hashes)))
|
||||
logger.debug(f"Querying '{len(hashes)}' hashes via traverse")
|
||||
remote_hashes = set(
|
||||
self.list_hashes_traverse(remote_size, remote_hashes, jobs, name)
|
||||
)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import logging
|
||||
|
||||
from .base import ObjectDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitObjectDB(ObjectDB):
|
||||
"""Dummy read-only ODB for uncached objects in external Git repos."""
|
||||
|
||||
def __init__(self, fs, path_info, **config):
|
||||
from dvc.fs.repo import RepoFileSystem
|
||||
|
||||
assert isinstance(fs, RepoFileSystem)
|
||||
super().__init__(fs, path_info)
|
||||
|
||||
def get(self, hash_info):
|
||||
raise NotImplementedError
|
||||
|
||||
def add(self, path_info, fs, hash_info, move=True, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def gc(self, used, jobs=None):
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,137 @@
|
||||
import logging
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Iterable, Optional, Set
|
||||
|
||||
from ..errors import ObjectDBError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.types import StrPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ObjectDBIndexBase(ABC):
|
||||
@abstractmethod
|
||||
def __init__(
|
||||
self, tmp_dir: "StrPath", name: str, dir_suffix: Optional[str] = None
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __iter__(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __contains__(self, hash_):
|
||||
pass
|
||||
|
||||
def hashes(self):
|
||||
return iter(self)
|
||||
|
||||
@abstractmethod
|
||||
def dir_hashes(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def clear(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update(self, dir_hashes: Iterable[str], file_hashes: Iterable[str]):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def intersection(self, hashes: Set[str]):
|
||||
pass
|
||||
|
||||
|
||||
class ObjectDBIndexNoop(ObjectDBIndexBase):
|
||||
"""No-op class for ODBs which are not indexed."""
|
||||
|
||||
def __init__(
|
||||
self, tmp_dir: "StrPath", name: str, dir_suffix: Optional[str] = None
|
||||
): # pylint: disable=super-init-not-called
|
||||
pass
|
||||
|
||||
def __iter__(self):
|
||||
return iter([])
|
||||
|
||||
def __contains__(self, hash_):
|
||||
return False
|
||||
|
||||
def dir_hashes(self):
|
||||
return []
|
||||
|
||||
def clear(self):
|
||||
pass
|
||||
|
||||
def update(self, dir_hashes: Iterable[str], file_hashes: Iterable[str]):
|
||||
pass
|
||||
|
||||
def intersection(self, hashes: Set[str]):
|
||||
return []
|
||||
|
||||
|
||||
class ObjectDBIndex(ObjectDBIndexBase):
|
||||
"""Class for indexing hashes in an ODB."""
|
||||
|
||||
INDEX_SUFFIX = ".idx"
|
||||
INDEX_DIR = "index"
|
||||
|
||||
def __init__(
|
||||
self, tmp_dir: "StrPath", name: str, dir_suffix: Optional[str] = None
|
||||
): # pylint: disable=super-init-not-called
|
||||
from diskcache import Index
|
||||
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
from dvc.utils.fs import makedirs
|
||||
|
||||
self.index_dir = os.path.join(tmp_dir, self.INDEX_DIR, name)
|
||||
makedirs(self.index_dir, exist_ok=True)
|
||||
self.fs = LocalFileSystem()
|
||||
self.index = Index(self.index_dir)
|
||||
|
||||
if not dir_suffix:
|
||||
dir_suffix = self.fs.CHECKSUM_DIR_SUFFIX
|
||||
self.dir_suffix = dir_suffix
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.index)
|
||||
|
||||
def __contains__(self, hash_):
|
||||
return hash_ in self.index
|
||||
|
||||
def dir_hashes(self):
|
||||
"""Iterate over .dir hashes stored in the index."""
|
||||
yield from (hash_ for hash_, is_dir in self.index.items() if is_dir)
|
||||
|
||||
def is_dir_hash(self, hash_: str):
|
||||
return hash_.endswith(self.dir_suffix)
|
||||
|
||||
def clear(self):
|
||||
"""Clear this index (to force re-indexing later)."""
|
||||
from diskcache import Timeout
|
||||
|
||||
try:
|
||||
self.index.clear()
|
||||
except Timeout as exc:
|
||||
raise ObjectDBError("Failed to clear ODB index") from exc
|
||||
|
||||
def update(self, dir_hashes: Iterable[str], file_hashes: Iterable[str]):
|
||||
"""Update this index, adding the specified hashes."""
|
||||
from diskcache import Timeout
|
||||
|
||||
try:
|
||||
with self.index.transact():
|
||||
for hash_ in dir_hashes:
|
||||
self.index[hash_] = True
|
||||
with self.index.transact():
|
||||
for hash_ in file_hashes:
|
||||
self.index[hash_] = False
|
||||
except Timeout as exc:
|
||||
raise ObjectDBError("Failed to update ODB index") from exc
|
||||
|
||||
def intersection(self, hashes: Set[str]):
|
||||
"""Iterate over values from `hashes` which exist in the index."""
|
||||
yield from hashes.intersection(self.index.keys())
|
||||
@@ -112,7 +112,7 @@ class LocalObjectDB(ObjectDB):
|
||||
# would get only the part of file. So, at first, the file should be
|
||||
# copied with the temporary name, and then original file should be
|
||||
# replaced by new.
|
||||
copyfile(path, tmp, name="Unprotecting '{}'".format(relpath(path)))
|
||||
copyfile(path, tmp, name=f"Unprotecting '{relpath(path)}'")
|
||||
remove(path)
|
||||
os.rename(tmp, path)
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from .base import ObjectDB
|
||||
|
||||
|
||||
class OSSObjectDB(ObjectDB):
|
||||
"""
|
||||
Temporary extra verification
|
||||
"""
|
||||
|
||||
DEFAULT_VERIFY = True
|
||||
@@ -0,0 +1,84 @@
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Dict
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from ..errors import ObjectFormatError
|
||||
from ..reference import ReferenceHashFile
|
||||
from .base import ObjectDB
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.base import BaseFileSystem
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.types import AnyPath, DvcPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReferenceObjectDB(ObjectDB):
|
||||
"""Reference ODB.
|
||||
|
||||
File objects are stored as ReferenceHashFiles which reference paths outside
|
||||
of the staging ODB fs. Tree objects are stored natively.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._fs_cache: Dict[tuple, "BaseFileSystem"] = {}
|
||||
self._obj_cache: Dict["HashInfo", "ReferenceHashFile"] = {}
|
||||
|
||||
def get(self, hash_info: "HashInfo"):
|
||||
if hash_info.isdir:
|
||||
return super().get(hash_info)
|
||||
try:
|
||||
return self._obj_cache[hash_info]
|
||||
except KeyError:
|
||||
pass
|
||||
path_info = self.hash_to_path(hash_info.value)
|
||||
try:
|
||||
with self.fs.open(path_info, "rb") as fobj:
|
||||
ref_file = ReferenceHashFile.from_bytes(
|
||||
fobj.read(), fs_cache=self._fs_cache
|
||||
)
|
||||
except OSError:
|
||||
raise FileNotFoundError
|
||||
try:
|
||||
ref_file.check(self, check_hash=False)
|
||||
except ObjectFormatError:
|
||||
self.fs.remove(path_info)
|
||||
raise
|
||||
self._obj_cache[hash_info] = ref_file
|
||||
return ref_file
|
||||
|
||||
def _add_file(
|
||||
self,
|
||||
from_fs: "BaseFileSystem",
|
||||
from_info: "AnyPath",
|
||||
to_info: "DvcPath",
|
||||
hash_info: "HashInfo",
|
||||
move: bool = False,
|
||||
):
|
||||
self.makedirs(to_info.parent)
|
||||
if hash_info.isdir:
|
||||
return super()._add_file(
|
||||
from_fs, from_info, to_info, hash_info, move
|
||||
)
|
||||
ref_file = ReferenceHashFile(from_info, from_fs, hash_info)
|
||||
self._obj_cache[hash_info] = ref_file
|
||||
ref_fobj = io.BytesIO(ref_file.to_bytes())
|
||||
ref_fobj.seek(0)
|
||||
try:
|
||||
self.fs.upload_fobj(ref_fobj, to_info)
|
||||
except OSError as exc:
|
||||
if isinstance(exc, FileExistsError) or (
|
||||
os.name == "nt"
|
||||
and exc.__context__
|
||||
and isinstance(exc.__context__, FileExistsError)
|
||||
):
|
||||
logger.debug("'%s' file already exists, skipping", to_info)
|
||||
else:
|
||||
raise
|
||||
if from_fs.scheme != Schemes.LOCAL:
|
||||
self._fs_cache[ReferenceHashFile.config_tuple(from_fs)] = from_fs
|
||||
@@ -25,15 +25,15 @@ this.message = (
|
||||
|
||||
def slow_link_guard(f):
|
||||
@wraps(f)
|
||||
def wrapper(remote, *args, **kwargs):
|
||||
def wrapper(odb, *args, **kwargs):
|
||||
if this.already_displayed:
|
||||
return f(remote, *args, **kwargs)
|
||||
return f(odb, *args, **kwargs)
|
||||
|
||||
if not remote.slow_link_warning or remote.cache_types:
|
||||
return f(remote, *args, **kwargs)
|
||||
if not odb.slow_link_warning or odb.cache_types:
|
||||
return f(odb, *args, **kwargs)
|
||||
|
||||
start = time.time()
|
||||
result = f(remote, *args, **kwargs)
|
||||
result = f(odb, *args, **kwargs)
|
||||
delta = time.time() - start
|
||||
|
||||
if delta >= this.timeout_seconds:
|
||||
@@ -1,90 +0,0 @@
|
||||
import errno
|
||||
import itertools
|
||||
import logging
|
||||
import posixpath
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.utils import to_chunks
|
||||
|
||||
from .base import ObjectDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SSHObjectDB(ObjectDB):
|
||||
def batch_exists(self, path_infos, callback):
|
||||
def _exists(chunk_and_channel):
|
||||
chunk, channel = chunk_and_channel
|
||||
ret = []
|
||||
for path in chunk:
|
||||
try:
|
||||
channel.stat(path)
|
||||
ret.append(True)
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.ENOENT:
|
||||
raise
|
||||
ret.append(False)
|
||||
callback(path)
|
||||
return ret
|
||||
|
||||
with self.fs.ssh() as ssh:
|
||||
channels = ssh.open_max_sftp_channels()
|
||||
max_workers = len(channels)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
paths = [path_info.path for path_info in path_infos]
|
||||
chunks = to_chunks(paths, num_chunks=max_workers)
|
||||
chunks_and_channels = zip(chunks, channels)
|
||||
outcome = executor.map(_exists, chunks_and_channels)
|
||||
results = list(itertools.chain.from_iterable(outcome))
|
||||
|
||||
return results
|
||||
|
||||
def hashes_exist(self, hashes, jobs=None, name=None):
|
||||
"""This is older implementation used in remote/base.py
|
||||
We are reusing it in RemoteSSH, because SSH's batch_exists proved to be
|
||||
faster than current approach (relying on exists(path_info)) applied in
|
||||
remote/base.
|
||||
"""
|
||||
if not self.fs.CAN_TRAVERSE:
|
||||
return list(set(hashes) & set(self.all()))
|
||||
|
||||
# possibly prompt for credentials before "Querying" progress output
|
||||
self.fs.ensure_credentials()
|
||||
|
||||
with Tqdm(
|
||||
desc="Querying "
|
||||
+ ("cache in " + name if name else "remote cache"),
|
||||
total=len(hashes),
|
||||
unit="file",
|
||||
) as pbar:
|
||||
|
||||
def exists_with_progress(chunks):
|
||||
return self.batch_exists(chunks, callback=pbar.update_msg)
|
||||
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=jobs or self.fs.jobs
|
||||
) as executor:
|
||||
path_infos = [self.hash_to_path_info(x) for x in hashes]
|
||||
chunks = to_chunks(path_infos, num_chunks=self.fs.jobs)
|
||||
results = executor.map(exists_with_progress, chunks)
|
||||
in_remote = itertools.chain.from_iterable(results)
|
||||
ret = list(itertools.compress(hashes, in_remote))
|
||||
return ret
|
||||
|
||||
def _list_paths(self, prefix=None, progress_callback=None):
|
||||
if prefix:
|
||||
root = posixpath.join(self.path_info.path, prefix[:2])
|
||||
else:
|
||||
root = self.path_info.path
|
||||
with self.fs.ssh() as ssh:
|
||||
if prefix and not ssh.exists(root):
|
||||
return
|
||||
# If we simply return an iterator then with above closes instantly
|
||||
if progress_callback:
|
||||
for path in ssh.walk_files(root):
|
||||
progress_callback()
|
||||
yield path
|
||||
else:
|
||||
yield from ssh.walk_files(root)
|
||||
@@ -7,3 +7,11 @@ class ObjectError(DvcException):
|
||||
|
||||
class ObjectFormatError(ObjectError):
|
||||
pass
|
||||
|
||||
|
||||
class ObjectDBError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
class ObjectDBPermissionError(ObjectDBError):
|
||||
pass
|
||||
|
||||
+11
-5
@@ -8,7 +8,9 @@ from .errors import ObjectFormatError
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.base import BaseFileSystem
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.types import DvcPath
|
||||
from dvc.types import AnyPath
|
||||
|
||||
from .db.base import ObjectDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,7 +18,7 @@ logger = logging.getLogger(__name__)
|
||||
class HashFile:
|
||||
def __init__(
|
||||
self,
|
||||
path_info: Optional["DvcPath"],
|
||||
path_info: Optional["AnyPath"],
|
||||
fs: Optional["BaseFileSystem"],
|
||||
hash_info: "HashInfo",
|
||||
name: Optional[str] = None,
|
||||
@@ -57,10 +59,9 @@ class HashFile:
|
||||
)
|
||||
)
|
||||
|
||||
def check(self, odb, check_hash=True):
|
||||
from .stage import get_file_hash
|
||||
|
||||
def check(self, odb: "ObjectDB", check_hash: bool = True):
|
||||
if not check_hash:
|
||||
assert self.fs
|
||||
if not self.fs.exists(self.path_info):
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), self.path_info
|
||||
@@ -68,6 +69,11 @@ class HashFile:
|
||||
else:
|
||||
return None
|
||||
|
||||
self._check_hash(odb)
|
||||
|
||||
def _check_hash(self, odb):
|
||||
from .stage import get_file_hash
|
||||
|
||||
actual = get_file_hash(
|
||||
self.path_info, self.fs, self.hash_info.name, odb.state
|
||||
)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
from typing import TYPE_CHECKING, Optional, Tuple
|
||||
|
||||
from .errors import ObjectFormatError
|
||||
from .file import HashFile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.base import BaseFileSystem
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.types import AnyPath
|
||||
|
||||
from .db.base import ObjectDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReferenceHashFile(HashFile):
|
||||
PARAM_PATH = "path"
|
||||
PARAM_HASH = "hash"
|
||||
PARAM_MTIME = "mtime"
|
||||
PARAM_SIZE = "size"
|
||||
PARAM_FS_CONFIG = "fs_config"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path_info: "AnyPath",
|
||||
fs: "BaseFileSystem",
|
||||
hash_info: "HashInfo",
|
||||
mtime: Optional[str] = None,
|
||||
size: Optional[int] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(path_info, fs, hash_info, **kwargs)
|
||||
cur_mtime, cur_size = self._get_mtime_and_size()
|
||||
self.mtime = cur_mtime if mtime is None else mtime
|
||||
self.hash_info.size = cur_size if size is None else size
|
||||
|
||||
def __str__(self):
|
||||
return f"ref object {self.hash_info} -> {self.path_info}"
|
||||
|
||||
def check(self, odb: "ObjectDB", check_hash: bool = True):
|
||||
assert self.fs
|
||||
if not self.fs.exists(self.path_info):
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), self.path_info
|
||||
)
|
||||
if not (self.mtime, self.size) == self._get_mtime_and_size():
|
||||
raise ObjectFormatError(f"{self} is changed")
|
||||
if check_hash:
|
||||
self._check_hash(odb)
|
||||
|
||||
def _get_mtime_and_size(self) -> Tuple[Optional[str], int]:
|
||||
from dvc.utils.fs import get_mtime_and_size
|
||||
|
||||
assert self.fs
|
||||
if hasattr(self.fs, "stat"):
|
||||
try:
|
||||
return get_mtime_and_size(self.path_info, self.fs)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return None, self.fs.getsize(self.path_info)
|
||||
|
||||
def to_bytes(self):
|
||||
from dvc.fs.repo import RepoFileSystem
|
||||
from dvc.path_info import PathInfo
|
||||
|
||||
# NOTE: dumping reference FS's this way is insecure, as the
|
||||
# fully parsed remote FS config will include credentials
|
||||
#
|
||||
# ReferenceHashFiles should currently only be serialized in
|
||||
# memory and not to disk
|
||||
path_info = self.path_info
|
||||
if isinstance(self.fs, RepoFileSystem):
|
||||
path_info = PathInfo(path_info).relative_to(self.fs.root_dir)
|
||||
|
||||
dict_ = {
|
||||
self.PARAM_PATH: path_info,
|
||||
self.PARAM_HASH: self.hash_info,
|
||||
self.PARAM_MTIME: self.mtime,
|
||||
self.PARAM_SIZE: self.size,
|
||||
self.PARAM_FS_CONFIG: self.config_tuple(self.fs),
|
||||
}
|
||||
try:
|
||||
return pickle.dumps(dict_)
|
||||
except pickle.PickleError as exc:
|
||||
raise ObjectFormatError(f"Could not pickle {self}") from exc
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes, fs_cache: Optional[dict] = None):
|
||||
from dvc.fs import get_fs_cls
|
||||
from dvc.fs.repo import RepoFileSystem
|
||||
|
||||
try:
|
||||
dict_ = pickle.loads(data)
|
||||
except pickle.PickleError as exc:
|
||||
raise ObjectFormatError("ReferenceHashFile is corrupted") from exc
|
||||
|
||||
try:
|
||||
path_info = dict_[cls.PARAM_PATH]
|
||||
hash_info = dict_[cls.PARAM_HASH]
|
||||
except KeyError as exc:
|
||||
raise ObjectFormatError("ReferenceHashFile is corrupted") from exc
|
||||
|
||||
scheme, config_pairs = dict_.get(cls.PARAM_FS_CONFIG)
|
||||
fs = fs_cache.get((scheme, config_pairs)) if fs_cache else None
|
||||
if not fs:
|
||||
config = dict(config_pairs)
|
||||
if RepoFileSystem.PARAM_REPO_URL in config:
|
||||
fs = RepoFileSystem(**config)
|
||||
path_info = fs.root_dir / path_info
|
||||
else:
|
||||
fs_cls = get_fs_cls(config, scheme=path_info.scheme)
|
||||
fs = fs_cls(**config)
|
||||
return ReferenceHashFile(
|
||||
path_info,
|
||||
fs,
|
||||
hash_info,
|
||||
mtime=dict_.get(cls.PARAM_MTIME),
|
||||
size=dict_.get(cls.PARAM_SIZE),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def config_tuple(fs: "BaseFileSystem"):
|
||||
return (
|
||||
fs.scheme,
|
||||
tuple(
|
||||
(key, value)
|
||||
for key, value in sorted(
|
||||
fs.config.items(), key=lambda item: item[0]
|
||||
)
|
||||
),
|
||||
)
|
||||
+211
-96
@@ -1,27 +1,45 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Dict, Tuple
|
||||
|
||||
from dvc.exceptions import DvcIgnoreInCollectedDirError
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.ignore import DvcIgnore
|
||||
from dvc.objects.file import HashFile
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.utils import file_md5
|
||||
|
||||
from .db.reference import ReferenceObjectDB
|
||||
from .file import HashFile
|
||||
|
||||
def _upload_file(path_info, fs, odb):
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.base import BaseFileSystem
|
||||
from dvc.types import AnyPath, DvcPath
|
||||
|
||||
from .db.base import ObjectDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_STAGING_MEMFS_PATH = "dvc-staging"
|
||||
|
||||
|
||||
def _upload_file(path_info, fs, odb, upload_odb):
|
||||
from dvc.utils import tmp_fname
|
||||
from dvc.utils.stream import HashedStreamReader
|
||||
|
||||
tmp_info = odb.path_info / tmp_fname()
|
||||
tmp_info = upload_odb.path_info / tmp_fname()
|
||||
with fs.open(path_info, mode="rb", chunk_size=fs.CHUNK_SIZE) as stream:
|
||||
stream = HashedStreamReader(stream)
|
||||
odb.fs.upload_fobj(
|
||||
upload_odb.fs.upload_fobj(
|
||||
stream, tmp_info, desc=path_info.name, size=fs.getsize(path_info)
|
||||
)
|
||||
|
||||
obj = HashFile(tmp_info, odb.fs, stream.hash_info)
|
||||
return path_info, obj
|
||||
odb.add(tmp_info, upload_odb.fs, stream.hash_info)
|
||||
return path_info, odb.get(stream.hash_info)
|
||||
|
||||
|
||||
def _get_file_hash(path_info, fs, name):
|
||||
@@ -59,19 +77,31 @@ def get_file_hash(path_info, fs, name, state=None):
|
||||
return hash_info
|
||||
|
||||
|
||||
def _get_file_obj(path_info, fs, name, odb=None, state=None, upload=False):
|
||||
if upload:
|
||||
def _get_file_obj(
|
||||
path_info, fs, name, odb=None, upload_odb=None, dry_run=False
|
||||
):
|
||||
state = odb.state if odb else None
|
||||
hash_info = get_file_hash(path_info, fs, name, state=state)
|
||||
if upload_odb and not dry_run:
|
||||
assert odb and name == "md5"
|
||||
return _upload_file(path_info, fs, odb)
|
||||
return _upload_file(path_info, fs, odb, upload_odb)
|
||||
|
||||
obj = HashFile(
|
||||
path_info, fs, get_file_hash(path_info, fs, name, state=state)
|
||||
)
|
||||
if dry_run:
|
||||
obj = HashFile(path_info, fs, hash_info)
|
||||
else:
|
||||
odb.add(path_info, fs, hash_info, move=False)
|
||||
obj = odb.get(hash_info)
|
||||
return path_info, obj
|
||||
|
||||
|
||||
def _build_objects(
|
||||
path_info, fs, name, odb, state, upload, dvcignore=None, **kwargs
|
||||
path_info,
|
||||
fs,
|
||||
name,
|
||||
dvcignore=None,
|
||||
jobs=None,
|
||||
no_progress_bar=False,
|
||||
**kwargs,
|
||||
):
|
||||
if dvcignore:
|
||||
walk_iterator = dvcignore.walk_files(fs, path_info)
|
||||
@@ -80,45 +110,31 @@ def _build_objects(
|
||||
with Tqdm(
|
||||
unit="md5",
|
||||
desc="Computing file/dir hashes (only done once)",
|
||||
disable=kwargs.pop("no_progress_bar", False),
|
||||
disable=no_progress_bar,
|
||||
) as pbar:
|
||||
worker = pbar.wrap_fn(
|
||||
partial(
|
||||
_get_file_obj,
|
||||
fs=fs,
|
||||
name=name,
|
||||
odb=odb,
|
||||
state=state,
|
||||
upload=upload,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=kwargs.pop("jobs", fs.hash_jobs)
|
||||
max_workers=jobs if jobs is not None else fs.hash_jobs
|
||||
) as executor:
|
||||
yield from executor.map(worker, walk_iterator)
|
||||
|
||||
|
||||
def _iter_objects(path_info, fs, name, odb, state, upload, **kwargs):
|
||||
if not upload and name in fs.DETAIL_FIELDS:
|
||||
for details in fs.find(path_info, detail=True):
|
||||
file_info = path_info.replace(path=details["name"])
|
||||
hash_info = HashInfo(name, details[name], size=details.get("size"))
|
||||
yield file_info, HashFile(file_info, fs, hash_info)
|
||||
|
||||
return None
|
||||
|
||||
yield from _build_objects(
|
||||
path_info, fs, name, odb, state, upload, **kwargs
|
||||
)
|
||||
def _iter_objects(path_info, fs, name, **kwargs):
|
||||
yield from _build_objects(path_info, fs, name, **kwargs)
|
||||
|
||||
|
||||
def _build_tree(path_info, fs, name, odb, state, upload, **kwargs):
|
||||
def _build_tree(path_info, fs, name, **kwargs):
|
||||
from .tree import Tree
|
||||
|
||||
tree = Tree(None, None, None)
|
||||
for file_info, obj in _iter_objects(
|
||||
path_info, fs, name, odb, state, upload, **kwargs
|
||||
):
|
||||
for file_info, obj in _iter_objects(path_info, fs, name, **kwargs):
|
||||
if DvcIgnore.DVCIGNORE_FILE == file_info.name:
|
||||
raise DvcIgnoreInCollectedDirError(file_info.parent)
|
||||
|
||||
@@ -131,85 +147,184 @@ def _build_tree(path_info, fs, name, odb, state, upload, **kwargs):
|
||||
# Yes, this is a BUG, as long as we permit "/" in
|
||||
# filenames on Windows and "\" on Unix
|
||||
tree.add(file_info.relative_to(path_info).parts, obj)
|
||||
|
||||
tree.digest()
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
def _get_tree_obj(path_info, fs, name, odb, state, upload, **kwargs):
|
||||
def _get_tree_obj(path_info, fs, fs_info, name, odb=None, **kwargs):
|
||||
from .tree import Tree
|
||||
|
||||
value = fs.info(path_info).get(name)
|
||||
if value:
|
||||
value = fs_info.get(name)
|
||||
if odb and value:
|
||||
hash_info = HashInfo(name, value)
|
||||
try:
|
||||
return Tree.load(odb, hash_info)
|
||||
tree = Tree.load(odb, hash_info)
|
||||
# NOTE: loaded entries are naive objects with hash_infos but no
|
||||
# path_info. For staging trees, obj.path_info should be relative
|
||||
# to the staging src `path_info` and src fs
|
||||
for key, entry in tree:
|
||||
entry.fs = fs
|
||||
entry.path_info = path_info.joinpath(*key)
|
||||
return tree
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
tree = _build_tree(path_info, fs, name, odb, state, upload, **kwargs)
|
||||
|
||||
odb.add(tree.path_info, tree.fs, tree.hash_info)
|
||||
if name != "md5":
|
||||
# NOTE: used only for external outputs. Initial reasoning was to be
|
||||
# able to validate .dir files right in the workspace (e.g. check s3
|
||||
# etag), but could be dropped for manual validation with regular md5,
|
||||
# that would be universal for all clouds.
|
||||
raw = odb.get(tree.hash_info)
|
||||
hash_info = get_file_hash(raw.path_info, raw.fs, name, state)
|
||||
tree.hash_info.name = hash_info.name
|
||||
tree.hash_info.value = hash_info.value
|
||||
if not tree.hash_info.value.endswith(".dir"):
|
||||
tree.hash_info.value += ".dir"
|
||||
odb.add(tree.path_info, tree.fs, tree.hash_info)
|
||||
|
||||
tree = _build_tree(path_info, fs, name, odb=odb, **kwargs)
|
||||
state = odb.state if odb and odb.state else None
|
||||
hash_info = None
|
||||
if state:
|
||||
hash_info = state.get( # pylint: disable=assignment-from-none
|
||||
path_info, fs
|
||||
)
|
||||
tree.digest(hash_info=hash_info)
|
||||
odb.add(tree.path_info, tree.fs, tree.hash_info, move=True)
|
||||
raw = odb.get(tree.hash_info)
|
||||
# cleanup unneeded memfs tmpfile and return tree based on the
|
||||
# ODB fs/path
|
||||
if odb.fs != tree.fs:
|
||||
tree.fs.remove(tree.path_info)
|
||||
tree.fs = raw.fs
|
||||
tree.path_info = raw.path_info
|
||||
return tree
|
||||
|
||||
|
||||
def stage(odb, path_info, fs, name, upload=False, **kwargs):
|
||||
assert path_info and (
|
||||
isinstance(path_info, str) or path_info.scheme == fs.scheme
|
||||
)
|
||||
_url_cache: Dict[str, str] = {}
|
||||
|
||||
|
||||
def _make_staging_url(path_info: "AnyPath"):
|
||||
from dvc.path_info import CloudURLInfo
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
url = CloudURLInfo(f"{Schemes.MEMORY}://{_STAGING_MEMFS_PATH}")
|
||||
if path_info:
|
||||
if isinstance(path_info, (str, pathlib.PurePath)):
|
||||
path = os.path.abspath(path_info)
|
||||
else:
|
||||
path = str(path_info)
|
||||
if path not in _url_cache:
|
||||
_url_cache[path] = hashlib.sha256(path.encode("utf-8")).hexdigest()
|
||||
url /= _url_cache[path]
|
||||
return url
|
||||
|
||||
|
||||
def _get_staging(odb: "ObjectDB") -> "ObjectDB":
|
||||
"""Return an ODB that can be used for staging objects.
|
||||
|
||||
Staging will be a reference ODB stored in the the global memfs.
|
||||
"""
|
||||
|
||||
from dvc.fs.memory import MemoryFileSystem
|
||||
|
||||
fs = MemoryFileSystem()
|
||||
path_info = _make_staging_url(odb.path_info)
|
||||
state = odb.state
|
||||
return ReferenceObjectDB(fs, path_info, state=state)
|
||||
|
||||
|
||||
def _load_from_state(odb, staging, path_info, fs, name):
|
||||
from . import check, load
|
||||
from .errors import ObjectFormatError
|
||||
from .tree import Tree
|
||||
|
||||
state = odb.state
|
||||
hash_info = state.get(path_info, fs)
|
||||
if hash_info:
|
||||
for odb_ in (odb, staging):
|
||||
if odb_.exists(hash_info):
|
||||
try:
|
||||
obj = load(odb_, hash_info)
|
||||
check(odb_, obj, check_hash=False)
|
||||
if isinstance(obj, Tree):
|
||||
obj.hash_info.nfiles = len(obj)
|
||||
for key, entry in obj:
|
||||
entry.fs = fs
|
||||
entry.path_info = path_info.joinpath(*key)
|
||||
else:
|
||||
obj.fs = fs
|
||||
obj.path_info = path_info
|
||||
assert obj.hash_info.name == name
|
||||
obj.hash_info.size = hash_info.size
|
||||
return odb_, obj
|
||||
except (ObjectFormatError, FileNotFoundError):
|
||||
pass
|
||||
raise FileNotFoundError
|
||||
|
||||
|
||||
def _stage_external_tree_info(odb, tree, name):
|
||||
# NOTE: used only for external outputs. Initial reasoning was to be
|
||||
# able to validate .dir files right in the workspace (e.g. check s3
|
||||
# etag), but could be dropped for manual validation with regular md5,
|
||||
# that would be universal for all clouds.
|
||||
assert odb and name != "md5"
|
||||
|
||||
odb.add(tree.path_info, tree.fs, tree.hash_info)
|
||||
raw = odb.get(tree.hash_info)
|
||||
hash_info = get_file_hash(raw.path_info, raw.fs, name, state=odb.state)
|
||||
tree.path_info = raw.path_info
|
||||
tree.fs = raw.fs
|
||||
tree.hash_info.name = hash_info.name
|
||||
tree.hash_info.value = hash_info.value
|
||||
if not tree.hash_info.value.endswith(".dir"):
|
||||
tree.hash_info.value += ".dir"
|
||||
return tree
|
||||
|
||||
|
||||
def stage(
|
||||
odb: "ObjectDB",
|
||||
path_info: "DvcPath",
|
||||
fs: "BaseFileSystem",
|
||||
name: str,
|
||||
upload: bool = False,
|
||||
dry_run: bool = False,
|
||||
**kwargs,
|
||||
) -> Tuple["ObjectDB", "HashFile"]:
|
||||
"""Stage (prepare) objects from the given path for addition to an ODB.
|
||||
|
||||
Returns at tuple of (staging_odb, object) where addition to the ODB can
|
||||
be completed by transferring the object from staging to the dest ODB.
|
||||
|
||||
If dry_run is True, object hashes will be computed and returned, but file
|
||||
objects themselves will not be added to the staging ODB (i.e. the resulting
|
||||
file objects cannot transferred from staging to another ODB).
|
||||
|
||||
If upload is True, files will be uploaded to a temporary path on the dest
|
||||
ODB filesystem, and staged objects will reference the uploaded path rather
|
||||
than the original source path.
|
||||
"""
|
||||
assert path_info and path_info.scheme == fs.scheme
|
||||
|
||||
details = fs.info(path_info)
|
||||
state = odb.state
|
||||
# pylint: disable=assignment-from-none
|
||||
hash_info = state.get(path_info, fs)
|
||||
|
||||
# If we have dir hash in state db, but dir cache file is lost,
|
||||
# then we need to recollect the dir via .get_dir_hash() call below,
|
||||
# see https://github.com/iterative/dvc/issues/2219 for context
|
||||
if (
|
||||
hash_info
|
||||
and hash_info.isdir
|
||||
and not odb.fs.exists(odb.hash_to_path_info(hash_info.value))
|
||||
):
|
||||
hash_info = None
|
||||
|
||||
if hash_info:
|
||||
from . import load
|
||||
from .tree import Tree
|
||||
|
||||
obj = load(odb, hash_info)
|
||||
if isinstance(obj, Tree):
|
||||
obj.hash_info.nfiles = len(obj)
|
||||
for key, entry in obj:
|
||||
entry.fs = fs
|
||||
entry.path_info = path_info.joinpath(*key)
|
||||
else:
|
||||
obj.fs = fs
|
||||
obj.path_info = path_info
|
||||
assert obj.hash_info.name == name
|
||||
obj.hash_info.size = hash_info.size
|
||||
return obj
|
||||
staging = _get_staging(odb)
|
||||
if odb:
|
||||
try:
|
||||
return _load_from_state(odb, staging, path_info, fs, name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
if details["type"] == "directory":
|
||||
obj = _get_tree_obj(path_info, fs, name, odb, state, upload, **kwargs)
|
||||
obj = _get_tree_obj(
|
||||
path_info,
|
||||
fs,
|
||||
details,
|
||||
name,
|
||||
odb=staging,
|
||||
upload_odb=odb if upload else None,
|
||||
dry_run=dry_run,
|
||||
**kwargs,
|
||||
)
|
||||
logger.debug("staged tree '%s'", obj)
|
||||
if name != "md5":
|
||||
obj = _stage_external_tree_info(odb, obj, name)
|
||||
else:
|
||||
_, obj = _get_file_obj(path_info, fs, name, odb, state, upload)
|
||||
_, obj = _get_file_obj(
|
||||
path_info,
|
||||
fs,
|
||||
name,
|
||||
odb=staging,
|
||||
upload_odb=odb if upload else None,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
if obj.hash_info:
|
||||
state.save(path_info, fs, obj.hash_info)
|
||||
if odb and odb.state and obj.hash_info:
|
||||
odb.state.save(path_info, fs, obj.hash_info)
|
||||
|
||||
return obj
|
||||
return staging, obj
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Dict, Iterable, NamedTuple, Optional, Set
|
||||
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .tree import Tree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .db.base import ObjectDB
|
||||
from .db.index import ObjectDBIndexBase
|
||||
from .file import HashFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StatusResult(NamedTuple):
|
||||
exists: Set["HashInfo"]
|
||||
missing: Set["HashInfo"]
|
||||
|
||||
|
||||
class CompareStatusResult(NamedTuple):
|
||||
ok: Set["HashInfo"]
|
||||
missing: Set["HashInfo"]
|
||||
new: Set["HashInfo"]
|
||||
deleted: Set["HashInfo"]
|
||||
|
||||
|
||||
def _indexed_dir_hashes(odb, index, dir_objs, name, cache_odb):
|
||||
# Validate our index by verifying all indexed .dir hashes
|
||||
# still exist on the remote
|
||||
dir_hashes = set(dir_objs.keys())
|
||||
indexed_dirs = set(index.dir_hashes())
|
||||
indexed_dir_exists = set()
|
||||
if indexed_dirs:
|
||||
indexed_dir_exists.update(odb.list_hashes_exists(indexed_dirs))
|
||||
missing_dirs = indexed_dirs.difference(indexed_dir_exists)
|
||||
if missing_dirs:
|
||||
logger.debug(
|
||||
"Remote cache missing indexed .dir hashes '%s', "
|
||||
"clearing remote index",
|
||||
", ".join(missing_dirs),
|
||||
)
|
||||
index.clear()
|
||||
|
||||
# Check if non-indexed (new) dir hashes exist on remote
|
||||
dir_exists = dir_hashes.intersection(indexed_dir_exists)
|
||||
dir_exists.update(odb.list_hashes_exists(dir_hashes - dir_exists))
|
||||
|
||||
# If .dir hash exists in the ODB, assume directory contents
|
||||
# also exists
|
||||
for dir_hash in dir_exists:
|
||||
tree = dir_objs.get(dir_hash)
|
||||
if not tree:
|
||||
try:
|
||||
tree = Tree.load(cache_odb, HashInfo(name, dir_hash))
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
file_hashes = [entry.hash_info.value for _, entry in tree]
|
||||
if dir_hash not in index:
|
||||
logger.debug(
|
||||
"Indexing new .dir '%s' with '%s' nested files",
|
||||
dir_hash,
|
||||
len(file_hashes),
|
||||
)
|
||||
index.update([dir_hash], file_hashes)
|
||||
yield from file_hashes
|
||||
yield tree.hash_info.value
|
||||
|
||||
|
||||
def status(
|
||||
odb: "ObjectDB",
|
||||
obj_ids: Iterable["HashInfo"],
|
||||
name: Optional[str] = None,
|
||||
index: Optional["ObjectDBIndexBase"] = None,
|
||||
cache_odb: Optional["ObjectDB"] = None,
|
||||
shallow: bool = True,
|
||||
**kwargs,
|
||||
) -> "StatusResult":
|
||||
"""Return status of whether or not the specified objects exist odb.
|
||||
|
||||
If cache_odb is set, trees will be loaded from cache_odb instead of odb
|
||||
when needed.
|
||||
|
||||
Status is returned as a tuple of:
|
||||
exists: objs that exist in odb
|
||||
missing: objs that do not exist in ODB
|
||||
"""
|
||||
logger.debug("Preparing to collect status from '%s'", odb.path_info)
|
||||
if not name:
|
||||
name = odb.fs.PARAM_CHECKSUM
|
||||
|
||||
if cache_odb is None:
|
||||
cache_odb = odb
|
||||
|
||||
hash_infos: Dict[str, "HashInfo"] = {}
|
||||
dir_objs: Dict[str, Optional["HashFile"]] = {}
|
||||
for hash_info in obj_ids:
|
||||
assert hash_info.value
|
||||
if hash_info.isdir:
|
||||
if shallow:
|
||||
tree = None
|
||||
else:
|
||||
tree = Tree.load(cache_odb, hash_info)
|
||||
for _, entry in tree:
|
||||
assert entry.hash_info and entry.hash_info.value
|
||||
hash_infos[entry.hash_info.value] = entry.hash_info
|
||||
if index:
|
||||
dir_objs[hash_info.value] = tree
|
||||
hash_infos[hash_info.value] = hash_info
|
||||
|
||||
if odb.fs.scheme == Schemes.MEMORY:
|
||||
# assume memfs staged objects already exist
|
||||
return StatusResult(set(hash_infos.values()), set())
|
||||
|
||||
hashes: Set[str] = set(hash_infos.keys())
|
||||
exists: Set[str] = set()
|
||||
|
||||
logger.debug("Collecting status from '%s'", odb.path_info)
|
||||
if index and hashes:
|
||||
if dir_objs:
|
||||
exists = hashes.intersection(
|
||||
_indexed_dir_hashes(odb, index, dir_objs, name, cache_odb)
|
||||
)
|
||||
hashes.difference_update(exists)
|
||||
if hashes:
|
||||
exists.update(index.intersection(hashes))
|
||||
hashes.difference_update(exists)
|
||||
|
||||
if hashes:
|
||||
exists.update(
|
||||
odb.hashes_exist(hashes, name=str(odb.path_info), **kwargs)
|
||||
)
|
||||
return StatusResult(
|
||||
{hash_infos[hash_] for hash_ in exists},
|
||||
{hash_infos[hash_] for hash_ in (hashes - exists)},
|
||||
)
|
||||
|
||||
|
||||
def compare_status(
|
||||
src: "ObjectDB",
|
||||
dest: "ObjectDB",
|
||||
obj_ids: Iterable["HashInfo"],
|
||||
log_missing: bool = True,
|
||||
check_deleted: bool = True,
|
||||
src_index: Optional["ObjectDBIndexBase"] = None,
|
||||
dest_index: Optional["ObjectDBIndexBase"] = None,
|
||||
**kwargs,
|
||||
) -> "CompareStatusResult":
|
||||
"""Compare status for the specified objects between two ODBs.
|
||||
|
||||
Status is returned as a tuple of:
|
||||
ok: hashes that exist in both src and dest
|
||||
missing: hashes that do not exist in neither src nor dest
|
||||
new: hashes that only exist in src
|
||||
deleted: hashes that only exist in dest
|
||||
"""
|
||||
if "cache_odb" not in kwargs:
|
||||
kwargs["cache_odb"] = src
|
||||
dest_exists, dest_missing = status(
|
||||
dest, obj_ids, index=dest_index, **kwargs
|
||||
)
|
||||
# for transfer operations we can skip src status check when all objects
|
||||
# already exist in dest
|
||||
if dest_missing or check_deleted:
|
||||
src_exists, src_missing = status(
|
||||
src, obj_ids, index=src_index, **kwargs
|
||||
)
|
||||
else:
|
||||
src_exists = dest_exists
|
||||
src_missing = set()
|
||||
result = CompareStatusResult(
|
||||
src_exists & dest_exists,
|
||||
src_missing & dest_missing,
|
||||
src_exists - dest_exists,
|
||||
dest_exists - src_exists,
|
||||
)
|
||||
if log_missing and result.missing:
|
||||
missing_desc = "\n".join(
|
||||
f"name: {hash_info.obj_name}, {hash_info}"
|
||||
for hash_info in result.missing
|
||||
)
|
||||
logger.warning(
|
||||
"Some of the cache files do not exist neither locally "
|
||||
f"nor on remote. Missing cache files:\n{missing_desc}"
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,239 @@
|
||||
import errno
|
||||
import itertools
|
||||
import logging
|
||||
from concurrent import futures
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial, wraps
|
||||
from typing import TYPE_CHECKING, Callable, Iterable, Optional
|
||||
|
||||
from funcy import split
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
|
||||
from .db.base import ObjectDB
|
||||
from .db.index import ObjectDBIndexBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _log_exceptions(func):
|
||||
@wraps(func)
|
||||
def wrapper(path_info, *args, **kwargs):
|
||||
try:
|
||||
func(path_info, *args, **kwargs)
|
||||
return 0
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# NOTE: this means we ran out of file descriptors and there is no
|
||||
# reason to try to proceed, as we will hit this error anyways.
|
||||
# pylint: disable=no-member
|
||||
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
|
||||
raise
|
||||
|
||||
logger.exception("failed to transfer '%s'", path_info)
|
||||
return 1
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _transfer(
|
||||
src, dest, dir_ids, file_ids, missing_ids, jobs, verify, move, **kwargs
|
||||
):
|
||||
func = _log_exceptions(dest.add)
|
||||
total = len(dir_ids) + len(file_ids)
|
||||
if total == 0:
|
||||
return 0
|
||||
with Tqdm(total=total, unit="file", desc="Transferring") as pbar:
|
||||
func = pbar.wrap_fn(func)
|
||||
with ThreadPoolExecutor(max_workers=jobs) as executor:
|
||||
processor = partial(
|
||||
_create_tasks,
|
||||
executor,
|
||||
jobs,
|
||||
func,
|
||||
src,
|
||||
verify,
|
||||
move,
|
||||
)
|
||||
processor.add_func = func
|
||||
_do_transfer(
|
||||
src,
|
||||
dest,
|
||||
dir_ids,
|
||||
file_ids,
|
||||
missing_ids,
|
||||
processor,
|
||||
verify=verify,
|
||||
move=move,
|
||||
**kwargs,
|
||||
)
|
||||
return total
|
||||
|
||||
|
||||
def _create_tasks(executor, jobs, func, src, verify, move, obj_ids):
|
||||
fails = 0
|
||||
hash_iter = iter(obj_ids)
|
||||
|
||||
def submit(hash_info):
|
||||
obj = src.get(hash_info)
|
||||
return executor.submit(
|
||||
func,
|
||||
obj.path_info,
|
||||
obj.fs,
|
||||
obj.hash_info,
|
||||
verify=verify,
|
||||
move=move,
|
||||
)
|
||||
|
||||
def create_taskset(amount):
|
||||
return {
|
||||
submit(hash_info)
|
||||
for hash_info in itertools.islice(hash_iter, amount)
|
||||
}
|
||||
|
||||
tasks = create_taskset(jobs * 5)
|
||||
while tasks:
|
||||
done, tasks = futures.wait(tasks, return_when=futures.FIRST_COMPLETED)
|
||||
fails += sum(task.result() for task in done)
|
||||
tasks.update(create_taskset(len(done)))
|
||||
return fails
|
||||
|
||||
|
||||
def _do_transfer(
|
||||
src: "ObjectDB",
|
||||
dest: "ObjectDB",
|
||||
dir_ids: Iterable["HashInfo"],
|
||||
file_ids: Iterable["HashInfo"],
|
||||
missing_ids: Iterable["HashInfo"],
|
||||
processor: Callable,
|
||||
src_index: Optional["ObjectDBIndexBase"] = None,
|
||||
dest_index: Optional["ObjectDBIndexBase"] = None,
|
||||
cache_odb: Optional["ObjectDB"] = None,
|
||||
**kwargs,
|
||||
):
|
||||
from dvc.exceptions import FileTransferError
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
|
||||
total_fails = 0
|
||||
succeeded_dir_objs = []
|
||||
all_file_ids = set(file_ids)
|
||||
|
||||
for dir_hash in dir_ids:
|
||||
from .tree import Tree
|
||||
|
||||
bound_file_ids = set()
|
||||
dir_obj: Optional["Tree"] = None
|
||||
for odb in (cache_odb, src):
|
||||
if odb is not None:
|
||||
try:
|
||||
dir_obj = Tree.load(odb, dir_hash)
|
||||
break
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
pass
|
||||
assert dir_obj
|
||||
entry_ids = {entry.hash_info for _, entry in dir_obj}
|
||||
|
||||
for file_hash in all_file_ids.copy():
|
||||
if file_hash in entry_ids:
|
||||
bound_file_ids.add(file_hash)
|
||||
all_file_ids.remove(file_hash)
|
||||
|
||||
dir_fails = processor(bound_file_ids)
|
||||
if dir_fails:
|
||||
logger.debug(
|
||||
"failed to upload full contents of '%s', "
|
||||
"aborting .dir file upload",
|
||||
dir_obj.name,
|
||||
)
|
||||
logger.error(
|
||||
"failed to upload '%s' to '%s'",
|
||||
src.get(dir_obj.hash_info).path_info,
|
||||
dest.get(dir_obj.hash_info).path_info,
|
||||
)
|
||||
total_fails += dir_fails + 1
|
||||
elif entry_ids.intersection(missing_ids):
|
||||
# if for some reason a file contained in this dir is
|
||||
# missing both locally and in the remote, we want to
|
||||
# push whatever file content we have, but should not
|
||||
# push .dir file
|
||||
logger.debug(
|
||||
"directory '%s' contains missing files,"
|
||||
"skipping .dir file upload",
|
||||
dir_obj.name,
|
||||
)
|
||||
else:
|
||||
raw_obj = src.get(dir_obj.hash_info)
|
||||
is_dir_failed = processor.add_func( # type: ignore[attr-defined]
|
||||
raw_obj.path_info,
|
||||
raw_obj.fs,
|
||||
raw_obj.hash_info,
|
||||
**kwargs,
|
||||
)
|
||||
total_fails += is_dir_failed
|
||||
if not is_dir_failed:
|
||||
succeeded_dir_objs.append(dir_obj)
|
||||
|
||||
# insert the rest
|
||||
total_fails += processor(all_file_ids)
|
||||
if total_fails:
|
||||
if src_index:
|
||||
src_index.clear()
|
||||
raise FileTransferError(total_fails)
|
||||
|
||||
# index successfully pushed dirs
|
||||
if dest_index:
|
||||
for dir_obj in succeeded_dir_objs:
|
||||
file_hashes = {entry.hash_info.value for _, entry in dir_obj}
|
||||
logger.debug(
|
||||
"Indexing pushed dir '%s' with '%s' nested files",
|
||||
dir_obj.hash_info,
|
||||
len(file_hashes),
|
||||
)
|
||||
assert dir_obj.hash_info and dir_obj.hash_info.value
|
||||
dest_index.update([dir_obj.hash_info.value], file_hashes)
|
||||
|
||||
|
||||
def transfer(
|
||||
src: "ObjectDB",
|
||||
dest: "ObjectDB",
|
||||
obj_ids: Iterable["HashInfo"],
|
||||
jobs: Optional[int] = None,
|
||||
verify: bool = False,
|
||||
move: bool = False,
|
||||
**kwargs,
|
||||
) -> int:
|
||||
"""Transfer (copy) the specified objects from one ODB to another.
|
||||
|
||||
Returns the number of successfully transferred objects
|
||||
"""
|
||||
from .status import compare_status
|
||||
|
||||
logger.debug(
|
||||
"Preparing to transfer data from '%s' to '%s'",
|
||||
src.path_info,
|
||||
dest.path_info,
|
||||
)
|
||||
if src == dest:
|
||||
return 0
|
||||
|
||||
status = compare_status(src, dest, obj_ids, check_deleted=False, **kwargs)
|
||||
if not status.new:
|
||||
return 0
|
||||
|
||||
dir_ids, file_ids = split(lambda hash_info: hash_info.isdir, status.new)
|
||||
if jobs is None:
|
||||
jobs = dest.fs.jobs
|
||||
|
||||
return _transfer(
|
||||
src,
|
||||
dest,
|
||||
set(dir_ids),
|
||||
set(file_ids),
|
||||
status.missing,
|
||||
jobs,
|
||||
verify,
|
||||
move,
|
||||
**kwargs,
|
||||
)
|
||||
+34
-30
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import posixpath
|
||||
from typing import TYPE_CHECKING, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Tuple
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
@@ -10,7 +10,7 @@ from .file import HashFile
|
||||
from .stage import get_file_hash
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .db.base import ObjectDB
|
||||
from dvc.hash_info import HashInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,7 +20,7 @@ class Tree(HashFile):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._dict = {}
|
||||
self._dict: Dict[Tuple[str], "HashFile"] = {}
|
||||
|
||||
@cached_property
|
||||
def trie(self):
|
||||
@@ -32,19 +32,23 @@ class Tree(HashFile):
|
||||
self.__dict__.pop("trie", None)
|
||||
self._dict[key] = obj
|
||||
|
||||
def digest(self):
|
||||
def digest(self, hash_info: Optional["HashInfo"] = None):
|
||||
from dvc.fs.memory import MemoryFileSystem
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.path_info import CloudURLInfo
|
||||
from dvc.utils import tmp_fname
|
||||
|
||||
memfs = MemoryFileSystem()
|
||||
path_info = PathInfo(tmp_fname(""))
|
||||
path_info = CloudURLInfo("memory://{}".format(tmp_fname("")))
|
||||
with memfs.open(path_info, "wb") as fobj:
|
||||
fobj.write(self.as_bytes())
|
||||
self.fs = memfs
|
||||
self.path_info = path_info
|
||||
self.hash_info = get_file_hash(path_info, memfs, "md5")
|
||||
self.hash_info.value += ".dir"
|
||||
if hash_info:
|
||||
self.hash_info = hash_info
|
||||
else:
|
||||
self.hash_info = get_file_hash(path_info, memfs, "md5")
|
||||
assert self.hash_info.value
|
||||
self.hash_info.value += ".dir"
|
||||
try:
|
||||
self.hash_info.size = sum(obj.size for _, obj in self)
|
||||
except TypeError:
|
||||
@@ -96,7 +100,6 @@ class Tree(HashFile):
|
||||
|
||||
@classmethod
|
||||
def load(cls, odb, hash_info):
|
||||
|
||||
obj = odb.get(hash_info)
|
||||
|
||||
try:
|
||||
@@ -121,39 +124,40 @@ class Tree(HashFile):
|
||||
|
||||
return tree
|
||||
|
||||
def filter(
|
||||
self, odb: "ObjectDB", prefix: Tuple[str], copy: bool = False
|
||||
) -> Optional[HashFile]:
|
||||
"""Return filter object(s) for this tree.
|
||||
def filter(self, prefix: Tuple[str]) -> Optional["Tree"]:
|
||||
"""Return a filtered copy of this tree that only contains entries
|
||||
inside prefix.
|
||||
|
||||
If copy is True, returned object will be a Tree containing
|
||||
filtered entries, but with hash_info copied from the original tree.
|
||||
The returned tree will contain the original tree's hash_info and
|
||||
path_info.
|
||||
|
||||
If copy is False, returned object will be a raw HashFile or Tree with
|
||||
newly computed hash_info for the filtered object.
|
||||
Returns an empty tree if no object exists at the specified prefix.
|
||||
"""
|
||||
tree = Tree(self.path_info, self.fs, self.hash_info)
|
||||
try:
|
||||
for key, obj in self.trie.items(prefix):
|
||||
tree.add(key, obj)
|
||||
except KeyError:
|
||||
pass
|
||||
return tree
|
||||
|
||||
def get(self, prefix: Tuple[str]) -> Optional[HashFile]:
|
||||
"""Return object at the specified prefix in this tree.
|
||||
|
||||
Returns None if no object exists at the specified prefix.
|
||||
"""
|
||||
obj = self._dict.get(prefix)
|
||||
if obj:
|
||||
if copy:
|
||||
tree = Tree(self.path_info, self.fs, self.hash_info)
|
||||
tree.add(prefix, obj)
|
||||
return tree
|
||||
return obj
|
||||
|
||||
if copy:
|
||||
tree = Tree(self.path_info, self.fs, self.hash_info)
|
||||
depth = 0
|
||||
else:
|
||||
tree = Tree(None, None, None)
|
||||
depth = len(prefix)
|
||||
tree = Tree(None, None, None)
|
||||
depth = len(prefix)
|
||||
try:
|
||||
for key, obj in self.trie.items(prefix):
|
||||
tree.add(key[depth:], obj)
|
||||
except KeyError:
|
||||
return None
|
||||
if not copy:
|
||||
tree.digest()
|
||||
odb.add(tree.path_info, tree.fs, tree.hash_info)
|
||||
tree.digest()
|
||||
return tree
|
||||
|
||||
|
||||
|
||||
+101
-42
@@ -25,16 +25,15 @@ from .fs.s3 import S3FileSystem
|
||||
from .hash_info import HashInfo
|
||||
from .istextfile import istextfile
|
||||
from .objects import Tree
|
||||
from .objects import save as osave
|
||||
from .objects.errors import ObjectFormatError
|
||||
from .objects.stage import stage as ostage
|
||||
from .objects.transfer import transfer as otransfer
|
||||
from .scheme import Schemes
|
||||
from .utils import relpath
|
||||
from .utils.fs import path_isin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .objects.db.base import ObjectDB
|
||||
from .objects.file import HashFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -404,21 +403,21 @@ class Output:
|
||||
return self.odb.hash_to_path_info(self.hash_info.value).url
|
||||
|
||||
def get_hash(self):
|
||||
if not self.use_cache:
|
||||
return ostage(
|
||||
self.repo.odb.local,
|
||||
self.path_info,
|
||||
self.fs,
|
||||
self.fs.PARAM_CHECKSUM,
|
||||
dvcignore=self.dvcignore,
|
||||
).hash_info
|
||||
return ostage(
|
||||
self.odb,
|
||||
if self.use_cache:
|
||||
odb = self.odb
|
||||
name = self.odb.fs.PARAM_CHECKSUM
|
||||
else:
|
||||
odb = self.repo.odb.local
|
||||
name = self.fs.PARAM_CHECKSUM
|
||||
_, obj = ostage(
|
||||
odb,
|
||||
self.path_info,
|
||||
self.fs,
|
||||
self.odb.fs.PARAM_CHECKSUM,
|
||||
name,
|
||||
dvcignore=self.dvcignore,
|
||||
).hash_info
|
||||
dry_run=not self.use_cache,
|
||||
)
|
||||
return obj.hash_info
|
||||
|
||||
@property
|
||||
def is_dir_checksum(self):
|
||||
@@ -545,7 +544,7 @@ class Output:
|
||||
logger.debug("Output '%s' didn't change. Skipping saving.", self)
|
||||
return
|
||||
|
||||
self.obj = ostage(
|
||||
_, self.obj = ostage(
|
||||
self.odb,
|
||||
self.path_info,
|
||||
self.fs,
|
||||
@@ -566,14 +565,28 @@ class Output:
|
||||
assert self.hash_info
|
||||
|
||||
if self.use_cache:
|
||||
obj = ostage(
|
||||
self.odb,
|
||||
filter_info or self.path_info,
|
||||
self.fs,
|
||||
self.odb.fs.PARAM_CHECKSUM,
|
||||
dvcignore=self.dvcignore,
|
||||
granular = (
|
||||
self.is_dir_checksum
|
||||
and filter_info
|
||||
and filter_info != self.path_info
|
||||
)
|
||||
objects.save(self.odb, obj)
|
||||
if granular:
|
||||
obj = self._commit_granular_dir(filter_info)
|
||||
else:
|
||||
staging, obj = ostage(
|
||||
self.odb,
|
||||
filter_info or self.path_info,
|
||||
self.fs,
|
||||
self.odb.fs.PARAM_CHECKSUM,
|
||||
dvcignore=self.dvcignore,
|
||||
)
|
||||
otransfer(
|
||||
staging,
|
||||
self.odb,
|
||||
{obj.hash_info},
|
||||
shallow=False,
|
||||
move=True,
|
||||
)
|
||||
checkout(
|
||||
filter_info or self.path_info,
|
||||
self.fs,
|
||||
@@ -585,6 +598,26 @@ class Output:
|
||||
)
|
||||
self.set_exec()
|
||||
|
||||
def _commit_granular_dir(self, filter_info):
|
||||
prefix = filter_info.relative_to(self.path_info).parts
|
||||
staging, save_obj = ostage(
|
||||
self.odb,
|
||||
self.path_info,
|
||||
self.fs,
|
||||
self.odb.fs.PARAM_CHECKSUM,
|
||||
dvcignore=self.dvcignore,
|
||||
)
|
||||
save_obj = save_obj.filter(prefix)
|
||||
checkout_obj = save_obj.get(prefix)
|
||||
otransfer(
|
||||
staging,
|
||||
self.odb,
|
||||
{save_obj.hash_info} | {entry.hash_info for _, entry in save_obj},
|
||||
shallow=True,
|
||||
move=True,
|
||||
)
|
||||
return checkout_obj
|
||||
|
||||
def dumpd(self):
|
||||
ret = copy(self.hash_info.to_dict())
|
||||
|
||||
@@ -670,7 +703,7 @@ class Output:
|
||||
|
||||
if filter_info and filter_info != self.path_info:
|
||||
prefix = filter_info.relative_to(self.path_info).parts
|
||||
obj = obj.filter(self.odb, prefix, **kwargs)
|
||||
obj = obj.get(prefix)
|
||||
|
||||
return obj
|
||||
|
||||
@@ -764,7 +797,7 @@ class Output:
|
||||
|
||||
upload = not (update and from_fs.isdir(from_info))
|
||||
jobs = jobs or min((from_fs.jobs, odb.fs.jobs))
|
||||
obj = ostage(
|
||||
staging, obj = ostage(
|
||||
odb,
|
||||
from_info,
|
||||
from_fs,
|
||||
@@ -773,7 +806,14 @@ class Output:
|
||||
jobs=jobs,
|
||||
no_progress_bar=no_progress_bar,
|
||||
)
|
||||
osave(odb, obj, jobs=jobs, move=upload)
|
||||
otransfer(
|
||||
staging,
|
||||
odb,
|
||||
{obj.hash_info},
|
||||
jobs=jobs,
|
||||
move=upload,
|
||||
shallow=False,
|
||||
)
|
||||
|
||||
self.hash_info = obj.hash_info
|
||||
return obj
|
||||
@@ -802,8 +842,11 @@ class Output:
|
||||
obj = self.odb.get(self.hash_info)
|
||||
try:
|
||||
objects.check(self.odb, obj)
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
self.repo.cloud.pull([obj], show_checksums=False, **kwargs)
|
||||
except FileNotFoundError:
|
||||
self.repo.cloud.pull([obj.hash_info], **kwargs)
|
||||
|
||||
if self.obj:
|
||||
return self.obj
|
||||
|
||||
try:
|
||||
self.obj = objects.load(self.odb, self.hash_info)
|
||||
@@ -814,8 +857,8 @@ class Output:
|
||||
|
||||
def collect_used_dir_cache(
|
||||
self, remote=None, force=False, jobs=None, filter_info=None
|
||||
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
|
||||
"""Fetch dir cache and return used objects for this out."""
|
||||
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
|
||||
"""Fetch dir cache and return used object IDs for this out."""
|
||||
|
||||
try:
|
||||
self.get_dir_cache(jobs=jobs, remote=remote)
|
||||
@@ -824,7 +867,7 @@ class Output:
|
||||
|
||||
try:
|
||||
objects.check(self.odb, self.odb.get(self.hash_info))
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
except FileNotFoundError:
|
||||
msg = (
|
||||
"Missing cache for directory '{}'. "
|
||||
"Cache for files inside will be lost. "
|
||||
@@ -837,14 +880,16 @@ class Output:
|
||||
)
|
||||
return {}
|
||||
|
||||
obj = self.get_obj(filter_info=filter_info, copy=True)
|
||||
self._set_obj_names(obj)
|
||||
return {None: {obj}}
|
||||
obj = self.get_obj()
|
||||
if filter_info and filter_info != self.path_info:
|
||||
prefix = filter_info.relative_to(self.path_info).parts
|
||||
obj = obj.filter(prefix)
|
||||
return {None: set(self._named_obj_ids(obj))}
|
||||
|
||||
def get_used_objs(
|
||||
self, **kwargs
|
||||
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
|
||||
"""Return filtered set of used objects for this out."""
|
||||
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
|
||||
"""Return filtered set of used object IDs for this out."""
|
||||
|
||||
if not self.use_cache:
|
||||
return {}
|
||||
@@ -877,24 +922,26 @@ class Output:
|
||||
obj = self.get_obj(filter_info=kwargs.get("filter_info"))
|
||||
if not obj:
|
||||
obj = self.odb.get(self.hash_info)
|
||||
self._set_obj_names(obj)
|
||||
|
||||
return {None: {obj}}
|
||||
return {None: set(self._named_obj_ids(obj))}
|
||||
|
||||
def _set_obj_names(self, obj):
|
||||
obj.name = str(self)
|
||||
def _named_obj_ids(self, obj):
|
||||
name = str(self)
|
||||
obj.hash_info.obj_name = name
|
||||
yield obj.hash_info
|
||||
if isinstance(obj, Tree):
|
||||
for key, entry_obj in obj:
|
||||
entry_obj.name = self.fs.sep.join([obj.name, *key])
|
||||
entry_obj.hash_info.obj_name = self.fs.sep.join([name, *key])
|
||||
yield entry_obj.hash_info
|
||||
|
||||
def get_used_external(
|
||||
self, **kwargs
|
||||
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
|
||||
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
|
||||
if not self.use_cache or not self.stage.is_repo_import:
|
||||
return {}
|
||||
|
||||
(dep,) = self.stage.deps
|
||||
return dep.get_used_objs()
|
||||
return dep.get_used_objs(**kwargs)
|
||||
|
||||
def _validate_output_path(self, path, stage=None):
|
||||
from dvc.dvcfile import is_valid_filename
|
||||
@@ -957,6 +1004,18 @@ class Output:
|
||||
def fspath(self):
|
||||
return self.path_info.fspath
|
||||
|
||||
@property
|
||||
def is_decorated(self) -> bool:
|
||||
return self.is_metric or self.is_plot
|
||||
|
||||
@property
|
||||
def is_metric(self) -> bool:
|
||||
return bool(self.metric) or bool(self.live)
|
||||
|
||||
@property
|
||||
def is_plot(self) -> bool:
|
||||
return bool(self.plot)
|
||||
|
||||
|
||||
ARTIFACT_SCHEMA = {
|
||||
**CHECKSUMS_SCHEMA,
|
||||
|
||||
+4
-2
@@ -52,7 +52,7 @@ class PathInfo(pathlib.PurePath, _BasePath):
|
||||
return relpath(path)
|
||||
|
||||
def __repr__(self):
|
||||
return "{}: '{}'".format(type(self).__name__, self)
|
||||
return f"{type(self).__name__}: '{self}'"
|
||||
|
||||
# This permits passing it to file utils directly in Python 3.6+
|
||||
def __fspath__(self):
|
||||
@@ -64,6 +64,8 @@ class PathInfo(pathlib.PurePath, _BasePath):
|
||||
|
||||
url = fspath
|
||||
|
||||
path = fspath
|
||||
|
||||
def relpath(self, other):
|
||||
return self.__class__(relpath(self, other))
|
||||
|
||||
@@ -174,7 +176,7 @@ class URLInfo(_BasePath):
|
||||
return self.url
|
||||
|
||||
def __repr__(self):
|
||||
return "{}: '{}'".format(type(self).__name__, self)
|
||||
return f"{type(self).__name__}: '{self}'"
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, (str, bytes)):
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
import sys
|
||||
from threading import RLock
|
||||
|
||||
import fsspec
|
||||
from tqdm import tqdm
|
||||
|
||||
from dvc.env import DVC_IGNORE_ISATTY
|
||||
@@ -136,6 +137,9 @@ class Tqdm(tqdm):
|
||||
|
||||
return wrapped
|
||||
|
||||
def as_callback(self, fs, path_info):
|
||||
return FsspecCallback(fs, path_info, self)
|
||||
|
||||
def close(self):
|
||||
self.postfix["info"] = ""
|
||||
# remove ETA (either unknown or zero); remove completed bar
|
||||
@@ -161,3 +165,26 @@ class Tqdm(tqdm):
|
||||
d["ncols_desc"] = d["ncols_info"] = 1
|
||||
d["prefix"] = ""
|
||||
return d
|
||||
|
||||
|
||||
class FsspecCallback(fsspec.Callback):
|
||||
def __init__(self, fs, path_info, progress_bar):
|
||||
self.fs = fs
|
||||
self.path_info = path_info
|
||||
self.progress_bar = progress_bar
|
||||
super().__init__()
|
||||
|
||||
def set_size(self, size):
|
||||
"""``set_size()`` is an API that might be called with ``None`` if the
|
||||
information is not already present on the caller. In that case, we'll
|
||||
retrieve the size from an ``info()`` call."""
|
||||
if size is None:
|
||||
size = self.fs.info(self.path_info)["size"]
|
||||
self.progress_bar.total = size
|
||||
self.progress_bar.refresh()
|
||||
|
||||
def relative_update(self, inc=1):
|
||||
self.progress_bar.update(inc)
|
||||
|
||||
def absolute_update(self, value):
|
||||
self.progress_bar.update_to(value)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
from ..fs import get_cloud_fs
|
||||
from .base import Remote
|
||||
from .local import LocalRemote
|
||||
|
||||
|
||||
def get_remote(repo, **kwargs):
|
||||
cls, config, path_info = get_cloud_fs(repo, **kwargs)
|
||||
fs = cls(**config)
|
||||
if fs.scheme == "local":
|
||||
return LocalRemote(fs, path_info, repo.tmp_dir, **config)
|
||||
return Remote(fs, path_info, repo.tmp_dir, **config)
|
||||
@@ -1,565 +0,0 @@
|
||||
import errno
|
||||
import hashlib
|
||||
import itertools
|
||||
import logging
|
||||
from concurrent import futures
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial, wraps
|
||||
|
||||
from dvc.exceptions import DownloadError, UploadError
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.tree import Tree
|
||||
|
||||
from ..progress import Tqdm
|
||||
from .index import RemoteIndex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATUS_OK = 1
|
||||
STATUS_MISSING = 2
|
||||
STATUS_NEW = 3
|
||||
STATUS_DELETED = 4
|
||||
|
||||
STATUS_MAP = {
|
||||
# (local_exists, remote_exists)
|
||||
(True, True): STATUS_OK,
|
||||
(False, False): STATUS_MISSING,
|
||||
(True, False): STATUS_NEW,
|
||||
(False, True): STATUS_DELETED,
|
||||
}
|
||||
|
||||
|
||||
def _log_exceptions(func, operation):
|
||||
@wraps(func)
|
||||
def wrapper(from_info, to_info, *args, **kwargs):
|
||||
try:
|
||||
func(from_info, to_info, *args, **kwargs)
|
||||
return 0
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# NOTE: this means we ran out of file descriptors and there is no
|
||||
# reason to try to proceed, as we will hit this error anyways.
|
||||
# pylint: disable=no-member
|
||||
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
|
||||
raise
|
||||
|
||||
logger.exception(
|
||||
"failed to %s '%s' to '%s'", operation, from_info, to_info
|
||||
)
|
||||
return 1
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def index_locked(f):
|
||||
@wraps(f)
|
||||
def wrapper(obj, *args, **kwargs):
|
||||
with obj.index:
|
||||
return f(obj, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class Remote:
|
||||
"""Cloud remote class.
|
||||
|
||||
Provides methods for indexing and garbage collecting fss which contain
|
||||
DVC remotes.
|
||||
"""
|
||||
|
||||
INDEX_CLS = RemoteIndex
|
||||
|
||||
def __init__(self, fs, path_info, tmp_dir, **config):
|
||||
from dvc.objects.db import get_odb
|
||||
|
||||
self.fs = fs
|
||||
config["tmp_dir"] = tmp_dir
|
||||
self.odb = get_odb(self.fs, path_info, **config)
|
||||
|
||||
url = getattr(path_info, "fspath", path_info.url)
|
||||
index_name = hashlib.sha256(url.encode("utf-8")).hexdigest()
|
||||
self.index = self.INDEX_CLS(
|
||||
tmp_dir, index_name, dir_suffix=self.fs.CHECKSUM_DIR_SUFFIX
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return "{class_name}: '{path_info}'".format(
|
||||
class_name=type(self).__name__,
|
||||
path_info=self.odb.path_info or "No path",
|
||||
)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.odb == other.odb
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.odb)
|
||||
|
||||
@classmethod
|
||||
def from_odb(cls, odb):
|
||||
config = odb.config
|
||||
return cls(
|
||||
odb.fs,
|
||||
odb.path_info,
|
||||
config.pop("tmp_dir"),
|
||||
**config,
|
||||
)
|
||||
|
||||
@index_locked
|
||||
def gc(self, *args, **kwargs):
|
||||
removed = self.odb.gc(*args, **kwargs)
|
||||
|
||||
if removed:
|
||||
self.index.clear()
|
||||
|
||||
return removed
|
||||
|
||||
@index_locked
|
||||
def status(
|
||||
self,
|
||||
cache,
|
||||
objs,
|
||||
jobs=None,
|
||||
show_checksums=False,
|
||||
download=False,
|
||||
log_missing=True,
|
||||
):
|
||||
# Return flattened dict containing all status info
|
||||
dir_status, file_status, _ = self._status(
|
||||
cache,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=download,
|
||||
log_missing=log_missing,
|
||||
)
|
||||
return dict(dir_status, **file_status)
|
||||
|
||||
def hashes_exist(self, hashes, **kwargs):
|
||||
hashes = set(hashes)
|
||||
indexed_hashes = set(self.index.intersection(hashes))
|
||||
hashes -= indexed_hashes
|
||||
indexed_hashes = list(indexed_hashes)
|
||||
logger.debug("Matched '{}' indexed hashes".format(len(indexed_hashes)))
|
||||
if not hashes:
|
||||
return indexed_hashes
|
||||
|
||||
return indexed_hashes + self.odb.hashes_exist(list(hashes), **kwargs)
|
||||
|
||||
def _status( # pylint: disable=unused-argument
|
||||
self,
|
||||
cache,
|
||||
objs,
|
||||
jobs=None,
|
||||
show_checksums=False,
|
||||
download=False,
|
||||
log_missing=True,
|
||||
):
|
||||
"""Return a tuple of (dir_status_info, file_status_info, dir_contents).
|
||||
|
||||
dir_status_info contains status for .dir files, file_status_info
|
||||
contains status for all other files, and dir_contents is a dict of
|
||||
{dir_hash: set(file_hash, ...)} which can be used to map
|
||||
a .dir file to its file contents.
|
||||
"""
|
||||
|
||||
logger.debug(f"Preparing to collect status from {self.odb.path_info}")
|
||||
md5s = set()
|
||||
dir_objs = {}
|
||||
for obj in objs:
|
||||
if obj.fs.scheme != cache.fs.scheme:
|
||||
continue
|
||||
md5s.add(obj.hash_info.value)
|
||||
if isinstance(obj, Tree):
|
||||
dir_objs[obj.hash_info.value] = obj
|
||||
for _, entry_obj in obj:
|
||||
md5s.add(entry_obj.hash_info.value)
|
||||
|
||||
logger.debug("Collecting information from local cache...")
|
||||
local_exists = frozenset(
|
||||
cache.hashes_exist(md5s, jobs=jobs, name=cache.cache_dir)
|
||||
)
|
||||
|
||||
# This is a performance optimization. We can safely assume that,
|
||||
# if the resources that we want to fetch are already cached,
|
||||
# there's no need to check the remote storage for the existence of
|
||||
# those files.
|
||||
if download and local_exists == md5s:
|
||||
remote_exists = local_exists
|
||||
else:
|
||||
logger.debug("Collecting information from remote cache...")
|
||||
remote_exists = set()
|
||||
if dir_objs:
|
||||
remote_exists.update(self._indexed_dir_hashes(dir_objs))
|
||||
md5s.difference_update(remote_exists)
|
||||
if md5s:
|
||||
remote_exists.update(
|
||||
self.hashes_exist(
|
||||
md5s, jobs=jobs, name=str(self.odb.path_info)
|
||||
)
|
||||
)
|
||||
return self._make_status(
|
||||
cache,
|
||||
objs,
|
||||
show_checksums,
|
||||
local_exists,
|
||||
remote_exists,
|
||||
log_missing,
|
||||
)
|
||||
|
||||
def _make_status(
|
||||
self,
|
||||
cache,
|
||||
objs,
|
||||
show_checksums,
|
||||
local_exists,
|
||||
remote_exists,
|
||||
log_missing,
|
||||
):
|
||||
def make_names(obj):
|
||||
if show_checksums or obj.name is None:
|
||||
name = obj.hash_info.value
|
||||
else:
|
||||
name = obj.name
|
||||
return {"name": name}
|
||||
|
||||
dir_status = {}
|
||||
file_status = {}
|
||||
dir_contents = {}
|
||||
for obj in objs:
|
||||
if obj.fs.scheme != cache.fs.scheme:
|
||||
continue
|
||||
|
||||
hash_ = obj.hash_info.value
|
||||
if isinstance(obj, Tree):
|
||||
dir_status[hash_] = make_names(obj)
|
||||
dir_contents[hash_] = set()
|
||||
for _, child_obj in obj:
|
||||
child_hash = child_obj.hash_info.value
|
||||
file_status[child_hash] = make_names(child_obj)
|
||||
dir_contents[hash_].add(child_hash)
|
||||
else:
|
||||
file_status[hash_] = make_names(obj)
|
||||
|
||||
self._fill_statuses(dir_status, local_exists, remote_exists)
|
||||
self._fill_statuses(file_status, local_exists, remote_exists)
|
||||
|
||||
if log_missing:
|
||||
self._log_missing_caches(dict(dir_status, **file_status))
|
||||
|
||||
return dir_status, file_status, dir_contents
|
||||
|
||||
def _indexed_dir_hashes(self, dir_objs):
|
||||
# Validate our index by verifying all indexed .dir hashes
|
||||
# still exist on the remote
|
||||
dir_md5s = set(dir_objs.keys())
|
||||
indexed_dirs = set(self.index.dir_hashes())
|
||||
indexed_dir_exists = set()
|
||||
if indexed_dirs:
|
||||
indexed_dir_exists.update(
|
||||
self.odb.list_hashes_exists(indexed_dirs)
|
||||
)
|
||||
missing_dirs = indexed_dirs.difference(indexed_dir_exists)
|
||||
if missing_dirs:
|
||||
logger.debug(
|
||||
"Remote cache missing indexed .dir hashes '{}', "
|
||||
"clearing remote index".format(", ".join(missing_dirs))
|
||||
)
|
||||
self.index.clear()
|
||||
|
||||
# Check if non-indexed (new) dir hashes exist on remote
|
||||
dir_exists = dir_md5s.intersection(indexed_dir_exists)
|
||||
dir_exists.update(self.odb.list_hashes_exists(dir_md5s - dir_exists))
|
||||
|
||||
# If .dir hash exists on the remote, assume directory contents
|
||||
# still exists on the remote
|
||||
for dir_hash in dir_exists:
|
||||
file_hashes = [
|
||||
obj.hash_info.value for _, obj in dir_objs[dir_hash]
|
||||
]
|
||||
if dir_hash not in self.index:
|
||||
logger.debug(
|
||||
"Indexing new .dir '{}' with '{}' nested files".format(
|
||||
dir_hash, len(file_hashes)
|
||||
)
|
||||
)
|
||||
self.index.update([dir_hash], file_hashes)
|
||||
yield dir_hash
|
||||
yield from file_hashes
|
||||
|
||||
@staticmethod
|
||||
def _fill_statuses(hash_info_dir, local_exists, remote_exists):
|
||||
# Using sets because they are way faster for lookups
|
||||
local = set(local_exists)
|
||||
remote = set(remote_exists)
|
||||
|
||||
for md5, info in hash_info_dir.items():
|
||||
status = STATUS_MAP[(md5 in local, md5 in remote)]
|
||||
info["status"] = status
|
||||
|
||||
def _get_plans(self, cache_obj, download, status_info, status):
|
||||
cache = []
|
||||
path_infos = []
|
||||
names = []
|
||||
hashes = []
|
||||
missing = []
|
||||
for md5, info in Tqdm(
|
||||
status_info.items(), desc="Analysing status", unit="file"
|
||||
):
|
||||
if info["status"] == status:
|
||||
cache.append(cache_obj.hash_to_path_info(md5))
|
||||
path_infos.append(self.odb.hash_to_path_info(md5))
|
||||
names.append(info["name"])
|
||||
hashes.append(md5)
|
||||
elif info["status"] == STATUS_MISSING:
|
||||
missing.append(md5)
|
||||
|
||||
if download:
|
||||
to_infos = cache
|
||||
from_infos = path_infos
|
||||
else:
|
||||
to_infos = path_infos
|
||||
from_infos = cache
|
||||
|
||||
return (from_infos, to_infos, names, hashes), missing
|
||||
|
||||
def _process(
|
||||
self,
|
||||
cache,
|
||||
objs,
|
||||
jobs=None,
|
||||
show_checksums=False,
|
||||
download=False,
|
||||
):
|
||||
logger.debug(
|
||||
"Preparing to {} '{}'".format(
|
||||
"download data from" if download else "upload data to",
|
||||
self.odb.path_info,
|
||||
)
|
||||
)
|
||||
|
||||
if download:
|
||||
func = _log_exceptions(self.fs.download_file, "download")
|
||||
status = STATUS_DELETED
|
||||
desc = "Downloading"
|
||||
else:
|
||||
func = _log_exceptions(self.fs.upload, "upload")
|
||||
status = STATUS_NEW
|
||||
desc = "Uploading"
|
||||
|
||||
if jobs is None:
|
||||
jobs = self.fs.jobs
|
||||
|
||||
dir_status, file_status, dir_contents = self._status(
|
||||
cache,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=download,
|
||||
)
|
||||
|
||||
dir_plans, _ = self._get_plans(cache, download, dir_status, status)
|
||||
file_plans, missing_files = self._get_plans(
|
||||
cache, download, file_status, status
|
||||
)
|
||||
|
||||
total = len(dir_plans[0]) + len(file_plans[0])
|
||||
if total == 0:
|
||||
return 0
|
||||
|
||||
with Tqdm(total=total, unit="file", desc=desc) as pbar:
|
||||
func = pbar.wrap_fn(func)
|
||||
self._process_plans(
|
||||
download,
|
||||
dir_plans,
|
||||
file_plans,
|
||||
dir_contents,
|
||||
missing_files,
|
||||
jobs,
|
||||
func,
|
||||
)
|
||||
|
||||
return total
|
||||
|
||||
def _process_plans(
|
||||
self,
|
||||
download,
|
||||
dir_plans,
|
||||
file_plans,
|
||||
dir_contents,
|
||||
missing_files,
|
||||
jobs,
|
||||
func,
|
||||
):
|
||||
with ThreadPoolExecutor(max_workers=jobs) as executor:
|
||||
processor = partial(self._create_tasks, executor, jobs, func)
|
||||
processor.transfer_func = func
|
||||
if download:
|
||||
self._download_plans(dir_plans, file_plans, processor)
|
||||
else:
|
||||
self._upload_plans(
|
||||
dir_plans,
|
||||
file_plans,
|
||||
dir_contents,
|
||||
missing_files,
|
||||
processor,
|
||||
)
|
||||
|
||||
def _create_tasks(self, executor, jobs, func, file_plans):
|
||||
fails = 0
|
||||
file_plan_iterator = iter(file_plans)
|
||||
|
||||
def create_taskset(amount):
|
||||
return {
|
||||
executor.submit(func, from_info, to_info, name)
|
||||
for from_info, to_info, name, _ in itertools.islice(
|
||||
file_plan_iterator, amount
|
||||
)
|
||||
}
|
||||
|
||||
tasks = create_taskset(jobs * 5)
|
||||
while tasks:
|
||||
done, tasks = futures.wait(
|
||||
tasks, return_when=futures.FIRST_COMPLETED
|
||||
)
|
||||
fails += sum(task.result() for task in done)
|
||||
tasks.update(create_taskset(len(done)))
|
||||
return fails
|
||||
|
||||
def _download_plans(self, dir_plans, file_plans, processor):
|
||||
plans = [*zip(*dir_plans), *zip(*file_plans)]
|
||||
fails = processor(plans)
|
||||
if fails:
|
||||
self.index.clear()
|
||||
raise DownloadError(fails)
|
||||
|
||||
def _upload_plans(
|
||||
self, dir_plans, file_plans, dir_contents, missing_files, processor
|
||||
):
|
||||
total_fails = 0
|
||||
succeeded_dir_hashes = []
|
||||
all_file_plans = list(zip(*file_plans))
|
||||
for dir_from_info, dir_to_info, dir_name, dir_hash in zip(*dir_plans):
|
||||
bound_file_plans = []
|
||||
directory_hashes = dir_contents[dir_hash]
|
||||
|
||||
for file_plan in all_file_plans.copy():
|
||||
if file_plan[-1] in directory_hashes:
|
||||
bound_file_plans.append(file_plan)
|
||||
all_file_plans.remove(file_plan)
|
||||
|
||||
dir_fails = processor(bound_file_plans)
|
||||
if dir_fails:
|
||||
logger.debug(
|
||||
"failed to upload full contents of '{}', "
|
||||
"aborting .dir file upload".format(dir_name)
|
||||
)
|
||||
logger.error(
|
||||
f"failed to upload '{dir_from_info}'"
|
||||
f" to '{dir_to_info}'"
|
||||
)
|
||||
total_fails += dir_fails + 1
|
||||
elif directory_hashes.intersection(missing_files):
|
||||
# if for some reason a file contained in this dir is
|
||||
# missing both locally and in the remote, we want to
|
||||
# push whatever file content we have, but should not
|
||||
# push .dir file
|
||||
logger.debug(
|
||||
"directory '%s' contains missing files,"
|
||||
"skipping .dir file upload",
|
||||
dir_name,
|
||||
)
|
||||
else:
|
||||
is_dir_failed = processor.transfer_func(
|
||||
dir_from_info, dir_to_info, dir_name
|
||||
)
|
||||
total_fails += is_dir_failed
|
||||
if not is_dir_failed:
|
||||
succeeded_dir_hashes.append(dir_hash)
|
||||
|
||||
# insert the rest
|
||||
total_fails += processor(all_file_plans)
|
||||
if total_fails:
|
||||
raise UploadError(total_fails)
|
||||
|
||||
# index successfully pushed dirs
|
||||
for dir_hash in succeeded_dir_hashes:
|
||||
file_hashes = dir_contents[dir_hash]
|
||||
logger.debug(
|
||||
"Indexing pushed dir '{}' with "
|
||||
"'{}' nested files".format(dir_hash, len(file_hashes))
|
||||
)
|
||||
self.index.update([dir_hash], file_hashes)
|
||||
|
||||
@index_locked
|
||||
def push(self, cache, objs, jobs=None, show_checksums=False):
|
||||
ret = self._process(
|
||||
cache,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=False,
|
||||
)
|
||||
|
||||
if self.fs.scheme == "local":
|
||||
for obj in objs:
|
||||
if obj.fs.scheme != "local":
|
||||
continue
|
||||
checksum = obj.hash_info.value
|
||||
cache_file = self.odb.hash_to_path_info(checksum)
|
||||
if self.fs.exists(cache_file):
|
||||
hash_info = HashInfo(self.fs.PARAM_CHECKSUM, checksum)
|
||||
self.odb.state.save(cache_file, self.fs, hash_info)
|
||||
self.odb.protect(cache_file)
|
||||
|
||||
return ret
|
||||
|
||||
@index_locked
|
||||
def pull(self, cache, objs, jobs=None, show_checksums=False):
|
||||
ret = self._process(
|
||||
cache,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=True,
|
||||
)
|
||||
|
||||
if not self.odb.verify:
|
||||
|
||||
def save_state(obj):
|
||||
checksum = obj.hash_info.value
|
||||
cache_file = cache.hash_to_path_info(checksum)
|
||||
if cache.fs.exists(cache_file):
|
||||
# We can safely save here, as existing corrupted files
|
||||
# will be removed upon status, while files corrupted
|
||||
# during download will not be moved from tmp_file
|
||||
# (see `BaseFileSystem.download()`)
|
||||
hash_info = HashInfo(cache.fs.PARAM_CHECKSUM, checksum)
|
||||
cache.state.save(cache_file, cache.fs, hash_info)
|
||||
cache.protect(cache_file)
|
||||
|
||||
for obj in objs:
|
||||
if obj.fs.scheme != "local":
|
||||
continue
|
||||
save_state(obj)
|
||||
if isinstance(obj, Tree):
|
||||
for _, entry_obj in obj:
|
||||
save_state(entry_obj)
|
||||
|
||||
return ret
|
||||
|
||||
@staticmethod
|
||||
def _log_missing_caches(hash_info_dict):
|
||||
missing_caches = [
|
||||
(md5, info)
|
||||
for md5, info in hash_info_dict.items()
|
||||
if info["status"] == STATUS_MISSING
|
||||
]
|
||||
if missing_caches:
|
||||
missing_desc = "\n".join(
|
||||
"name: {}, md5: {}".format(info["name"], md5)
|
||||
for md5, info in missing_caches
|
||||
)
|
||||
msg = (
|
||||
"Some of the cache files do not exist neither locally "
|
||||
"nor on remote. Missing cache files:\n{}".format(missing_desc)
|
||||
)
|
||||
logger.warning(msg)
|
||||
@@ -1,213 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
from funcy import lchunks
|
||||
|
||||
from dvc.state import _connect_sqlite
|
||||
from dvc.utils.fs import makedirs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemoteIndexNoop:
|
||||
"""No-op class for remotes which are not indexed (i.e. local)."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
pass
|
||||
|
||||
def __exit__(self, typ, value, tbck):
|
||||
pass
|
||||
|
||||
def __iter__(self):
|
||||
return iter([])
|
||||
|
||||
def __contains__(self, hash_):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def hashes():
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def dir_hashes():
|
||||
return []
|
||||
|
||||
def load(self):
|
||||
pass
|
||||
|
||||
def dump(self):
|
||||
pass
|
||||
|
||||
def clear(self):
|
||||
pass
|
||||
|
||||
def update(self, *args):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def intersection(*args):
|
||||
return []
|
||||
|
||||
|
||||
class RemoteIndex:
|
||||
"""Class for indexing remote hashes in a sqlite3 database.
|
||||
|
||||
Args:
|
||||
repo: repo for this remote index.
|
||||
name: name for this index. Index db will be loaded from and saved to
|
||||
``.dvc/tmp/index/{name}.idx``.
|
||||
dir_suffix: suffix used for naming directory hashes
|
||||
"""
|
||||
|
||||
INDEX_SUFFIX = ".idx"
|
||||
VERSION = 1
|
||||
INDEX_TABLE = "remote_index"
|
||||
INDEX_TABLE_LAYOUT = "checksum TEXT PRIMARY KEY, dir INTEGER NOT NULL"
|
||||
INDEX_DIR = "index"
|
||||
|
||||
def __init__(self, tmp_dir, name, dir_suffix=".dir"):
|
||||
self.path = os.path.join(
|
||||
tmp_dir, self.INDEX_DIR, f"{name}{self.INDEX_SUFFIX}"
|
||||
)
|
||||
|
||||
self.dir_suffix = dir_suffix
|
||||
self.database = None
|
||||
self.cursor = None
|
||||
self.modified = False
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def __iter__(self):
|
||||
cmd = f"SELECT checksum FROM {self.INDEX_TABLE}"
|
||||
for (hash_,) in self._execute(cmd):
|
||||
yield hash_
|
||||
|
||||
def __enter__(self):
|
||||
self.lock.acquire()
|
||||
self.load()
|
||||
|
||||
def __exit__(self, typ, value, tbck):
|
||||
self.dump()
|
||||
self.lock.release()
|
||||
|
||||
def __contains__(self, hash_):
|
||||
cmd = "SELECT checksum FROM {} WHERE checksum = (?)".format(
|
||||
self.INDEX_TABLE
|
||||
)
|
||||
self._execute(cmd, (hash_,))
|
||||
return self.cursor.fetchone() is not None
|
||||
|
||||
def hashes(self):
|
||||
"""Iterate over hashes stored in the index."""
|
||||
return iter(self)
|
||||
|
||||
def dir_hashes(self):
|
||||
"""Iterate over .dir hashes stored in the index."""
|
||||
cmd = f"SELECT checksum FROM {self.INDEX_TABLE} WHERE dir = 1"
|
||||
for (hash_,) in self._execute(cmd):
|
||||
yield hash_
|
||||
|
||||
def is_dir_hash(self, hash_):
|
||||
return hash_.endswith(self.dir_suffix)
|
||||
|
||||
def _execute(self, cmd, parameters=()):
|
||||
return self.cursor.execute(cmd, parameters)
|
||||
|
||||
def _executemany(self, cmd, seq_of_parameters):
|
||||
return self.cursor.executemany(cmd, seq_of_parameters)
|
||||
|
||||
def _prepare_db(self, empty=False):
|
||||
if not empty:
|
||||
cmd = "PRAGMA user_version;"
|
||||
self._execute(cmd)
|
||||
ret = self.cursor.fetchall()
|
||||
assert len(ret) == 1
|
||||
assert len(ret[0]) == 1
|
||||
assert isinstance(ret[0][0], int)
|
||||
version = ret[0][0]
|
||||
|
||||
if version != self.VERSION:
|
||||
logger.error(
|
||||
"Index file version '{}' will be reformatted "
|
||||
"to the current version '{}'.".format(
|
||||
version, self.VERSION
|
||||
)
|
||||
)
|
||||
cmd = "DROP TABLE IF EXISTS {};"
|
||||
self._execute(cmd.format(self.INDEX_TABLE))
|
||||
|
||||
cmd = "CREATE TABLE IF NOT EXISTS {} ({})"
|
||||
self._execute(cmd.format(self.INDEX_TABLE, self.INDEX_TABLE_LAYOUT))
|
||||
|
||||
cmd = "PRAGMA user_version = {};"
|
||||
self._execute(cmd.format(self.VERSION))
|
||||
|
||||
def load(self):
|
||||
"""(Re)load this index database."""
|
||||
retries = 1
|
||||
while True:
|
||||
assert self.database is None
|
||||
assert self.cursor is None
|
||||
|
||||
empty = not os.path.isfile(self.path)
|
||||
makedirs(os.path.dirname(self.path), exist_ok=True)
|
||||
self.database = _connect_sqlite(self.path, {"nolock": 1})
|
||||
self.cursor = self.database.cursor()
|
||||
|
||||
try:
|
||||
self._prepare_db(empty=empty)
|
||||
return
|
||||
except sqlite3.DatabaseError:
|
||||
self.cursor.close()
|
||||
self.database.close()
|
||||
self.database = None
|
||||
self.cursor = None
|
||||
if retries > 0:
|
||||
os.unlink(self.path)
|
||||
retries -= 1
|
||||
else:
|
||||
raise
|
||||
|
||||
def dump(self):
|
||||
"""Save this index database."""
|
||||
assert self.database is not None
|
||||
|
||||
self.database.commit()
|
||||
self.cursor.close()
|
||||
self.database.close()
|
||||
self.database = None
|
||||
self.cursor = None
|
||||
|
||||
def clear(self):
|
||||
"""Clear this index (to force re-indexing later).
|
||||
|
||||
Changes to the index will not committed until dump() is called.
|
||||
"""
|
||||
cmd = f"DELETE FROM {self.INDEX_TABLE}"
|
||||
self._execute(cmd)
|
||||
|
||||
def update(self, dir_hashes, file_hashes):
|
||||
"""Update this index, adding the specified hashes.
|
||||
|
||||
Changes to the index will not committed until dump() is called.
|
||||
"""
|
||||
cmd = "INSERT OR IGNORE INTO {} (checksum, dir) VALUES (?, ?)".format(
|
||||
self.INDEX_TABLE
|
||||
)
|
||||
self._executemany(cmd, ((hash_, True) for hash_ in dir_hashes))
|
||||
self._executemany(cmd, ((hash_, False) for hash_ in file_hashes))
|
||||
|
||||
def intersection(self, hashes):
|
||||
"""Iterate over values from `hashes` which exist in the index."""
|
||||
# sqlite has a compile time limit of 999, see:
|
||||
# https://www.sqlite.org/c3ref/c_limit_attached.html#sqlitelimitvariablenumber
|
||||
for chunk in lchunks(999, hashes):
|
||||
cmd = "SELECT checksum FROM {} WHERE checksum IN ({})".format(
|
||||
self.INDEX_TABLE, ",".join("?" for hash_ in chunk)
|
||||
)
|
||||
for (hash_,) in self._execute(cmd, chunk):
|
||||
yield hash_
|
||||
@@ -1,6 +0,0 @@
|
||||
from .base import Remote
|
||||
from .index import RemoteIndexNoop
|
||||
|
||||
|
||||
class LocalRemote(Remote):
|
||||
INDEX_CLS = RemoteIndexNoop # type: ignore[assignment]
|
||||
+34
-84
@@ -3,9 +3,9 @@ import os
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Callable, Optional
|
||||
from typing import TYPE_CHECKING, Callable, Optional, Set
|
||||
|
||||
from funcy import cached_property, cat
|
||||
from funcy import cached_property
|
||||
|
||||
from dvc.exceptions import FileMissingError
|
||||
from dvc.exceptions import IsADirectoryError as DvcIsADirectoryError
|
||||
@@ -14,11 +14,9 @@ from dvc.ignore import DvcIgnoreFilter
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.utils.fs import path_isin
|
||||
|
||||
from .graph import build_graph, build_outs_graph, get_pipelines
|
||||
from .trie import build_outs_trie
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.base import BaseFileSystem
|
||||
from dvc.objects.file import HashFile
|
||||
from dvc.scm import Base
|
||||
|
||||
|
||||
@@ -57,7 +55,6 @@ class Repo:
|
||||
DVC_DIR = ".dvc"
|
||||
|
||||
from dvc.repo.add import add
|
||||
from dvc.repo.brancher import brancher
|
||||
from dvc.repo.checkout import checkout
|
||||
from dvc.repo.commit import commit
|
||||
from dvc.repo.destroy import destroy
|
||||
@@ -207,6 +204,12 @@ class Repo:
|
||||
def __str__(self):
|
||||
return self.url or self.root_dir
|
||||
|
||||
@cached_property
|
||||
def index(self):
|
||||
from dvc.repo.index import Index
|
||||
|
||||
return Index(self)
|
||||
|
||||
@staticmethod
|
||||
def open(url, *args, **kwargs):
|
||||
if url is None:
|
||||
@@ -323,23 +326,10 @@ class Repo:
|
||||
|
||||
self.scm.ignore_list(flist)
|
||||
|
||||
def check_modified_graph(self, new_stages, old_stages=None):
|
||||
"""Generate graph including the new stage to check for errors"""
|
||||
# Building graph might be costly for the ones with many DVC-files,
|
||||
# so we provide this undocumented hack to skip it. See [1] for
|
||||
# more details. The hack can be used as:
|
||||
#
|
||||
# repo = Repo(...)
|
||||
# repo._skip_graph_checks = True
|
||||
# repo.add(...)
|
||||
#
|
||||
# A user should care about not duplicating outs and not adding cycles,
|
||||
# otherwise DVC might have an undefined behaviour.
|
||||
#
|
||||
# [1] https://github.com/iterative/dvc/issues/2671
|
||||
if not getattr(self, "_skip_graph_checks", False):
|
||||
existing_stages = self.stages if old_stages is None else old_stages
|
||||
build_graph(existing_stages + new_stages)
|
||||
def brancher(self, *args, **kwargs):
|
||||
from dvc.repo.brancher import brancher
|
||||
|
||||
return brancher(self, *args, **kwargs)
|
||||
|
||||
def used_objs(
|
||||
self,
|
||||
@@ -373,16 +363,14 @@ class Repo:
|
||||
"""
|
||||
used = defaultdict(set)
|
||||
|
||||
def _add_suffix(objs, suffix):
|
||||
from dvc.objects.tree import Tree
|
||||
def _add_suffix(objs: Set["HashFile"], suffix: str) -> None:
|
||||
from itertools import chain
|
||||
|
||||
for obj in objs:
|
||||
from dvc.objects import iterobjs
|
||||
|
||||
for obj in chain.from_iterable(map(iterobjs, objs)):
|
||||
if obj.name is not None:
|
||||
obj.name += suffix
|
||||
if isinstance(obj, Tree):
|
||||
for _, entry_obj in obj:
|
||||
if entry_obj.name is not None:
|
||||
entry_obj.name += suffix
|
||||
|
||||
for branch in self.brancher(
|
||||
revs=revs,
|
||||
@@ -391,25 +379,17 @@ class Repo:
|
||||
all_commits=all_commits,
|
||||
all_experiments=all_experiments,
|
||||
):
|
||||
targets = targets or [None]
|
||||
|
||||
pairs = cat(
|
||||
self.stage.collect_granular(
|
||||
target, recursive=recursive, with_deps=with_deps
|
||||
)
|
||||
for target in targets
|
||||
)
|
||||
|
||||
for stage, filter_info in pairs:
|
||||
for odb, objs in stage.get_used_objs(
|
||||
remote=remote,
|
||||
force=force,
|
||||
jobs=jobs,
|
||||
filter_info=filter_info,
|
||||
).items():
|
||||
if branch:
|
||||
_add_suffix(objs, f" ({branch})")
|
||||
used[odb].update(objs)
|
||||
for odb, objs in self.index.used_objs(
|
||||
targets,
|
||||
remote=remote,
|
||||
force=force,
|
||||
jobs=jobs,
|
||||
recursive=recursive,
|
||||
with_deps=with_deps,
|
||||
).items():
|
||||
if branch:
|
||||
_add_suffix(objs, f" ({branch})")
|
||||
used[odb].update(objs)
|
||||
|
||||
if used_run_cache:
|
||||
for odb, objs in self.stage_cache.get_used_objs(
|
||||
@@ -419,39 +399,13 @@ class Repo:
|
||||
|
||||
return used
|
||||
|
||||
@cached_property
|
||||
def outs_trie(self):
|
||||
return build_outs_trie(self.stages)
|
||||
|
||||
@cached_property
|
||||
def graph(self):
|
||||
return build_graph(self.stages, self.outs_trie)
|
||||
|
||||
@cached_property
|
||||
def outs_graph(self):
|
||||
return build_outs_graph(self.graph, self.outs_trie)
|
||||
|
||||
@cached_property
|
||||
def pipelines(self):
|
||||
return get_pipelines(self.graph)
|
||||
|
||||
@cached_property
|
||||
def stages(self):
|
||||
"""
|
||||
Walks down the root directory looking for Dvcfiles,
|
||||
skipping the directories that are related with
|
||||
any SCM (e.g. `.git`), DVC itself (`.dvc`), or directories
|
||||
tracked by DVC (e.g. `dvc add data` would skip `data/`)
|
||||
|
||||
NOTE: For large repos, this could be an expensive
|
||||
operation. Consider using some memoization.
|
||||
"""
|
||||
error_handler = self.stage_collection_error_handler
|
||||
return self.stage.collect_repo(onerror=error_handler)
|
||||
@property
|
||||
def stages(self): # obsolete, only for backward-compatibility
|
||||
return self.index.stages
|
||||
|
||||
def find_outs_by_path(self, path, outs=None, recursive=False, strict=True):
|
||||
# using `outs_graph` to ensure graph checks are run
|
||||
outs = outs or self.outs_graph
|
||||
outs = outs or self.index.outs_graph
|
||||
|
||||
abs_path = os.path.abspath(path)
|
||||
path_info = PathInfo(abs_path)
|
||||
@@ -512,11 +466,7 @@ class Repo:
|
||||
def _reset(self):
|
||||
self.state.close()
|
||||
self.scm._reset() # pylint: disable=protected-access
|
||||
self.__dict__.pop("outs_trie", None)
|
||||
self.__dict__.pop("outs_graph", None)
|
||||
self.__dict__.pop("graph", None)
|
||||
self.__dict__.pop("stages", None)
|
||||
self.__dict__.pop("pipelines", None)
|
||||
self.__dict__.pop("index", None)
|
||||
self.__dict__.pop("dvcignore", None)
|
||||
|
||||
def __enter__(self):
|
||||
|
||||
+11
-6
@@ -43,7 +43,14 @@ def check_recursive_and_fname(args):
|
||||
|
||||
|
||||
def transform_targets(args):
|
||||
args.targets = ensure_list(args.targets)
|
||||
from funcy import count_reps
|
||||
|
||||
counts = count_reps(ensure_list(args.targets))
|
||||
dupes = [key for key, count in counts.items() if count > 1]
|
||||
if dupes:
|
||||
msg = ", ".join(f"[b]{key}[/]" for key in dupes)
|
||||
ui.error_write(f"ignoring duplicated targets: {msg}", styled=True)
|
||||
args.targets = list(counts)
|
||||
|
||||
|
||||
def check_arg_combinations(args):
|
||||
@@ -162,19 +169,17 @@ def add( # noqa: C901
|
||||
desc = "Collecting targets"
|
||||
stages_it = create_stages(repo, add_targets, fname, transfer, **kwargs)
|
||||
stages = list(ui.progress(stages_it, desc=desc, unit="file"))
|
||||
|
||||
msg = "Collecting stages from the workspace"
|
||||
with translate_graph_error(stages), ui.status(msg) as status:
|
||||
# remove existing stages that are to-be replaced with these
|
||||
# new stages for the graph checks.
|
||||
old_stages = set(repo.stages) - set(stages)
|
||||
new_index = repo.index.update(stages)
|
||||
status.update("Checking graph")
|
||||
repo.check_modified_graph(stages, list(old_stages))
|
||||
new_index.check_graph()
|
||||
|
||||
odb = None
|
||||
if to_remote:
|
||||
remote = repo.cloud.get_remote(kwargs.get("remote"), "add")
|
||||
odb = remote.odb
|
||||
odb = repo.cloud.get_remote_odb(kwargs.get("remote"), "add")
|
||||
|
||||
with warn_link_failures() as link_failures:
|
||||
for stage, source in zip(progress_iter(stages), sources):
|
||||
|
||||
@@ -28,13 +28,7 @@ def _fspath_dir(path):
|
||||
|
||||
|
||||
def _remove_unused_links(repo):
|
||||
used = [
|
||||
out.fspath
|
||||
for stage in repo.stages
|
||||
for out in stage.outs
|
||||
if out.scheme == "local"
|
||||
]
|
||||
|
||||
used = [out.fspath for out in repo.index.outs if out.scheme == "local"]
|
||||
unused = repo.state.get_unused_links(used, repo.fs)
|
||||
ret = [_fspath_dir(u) for u in unused]
|
||||
repo.state.remove_links(unused, repo.fs)
|
||||
|
||||
+9
-15
@@ -20,12 +20,9 @@ DvcPaths = List[DvcPath]
|
||||
def _collect_outs(
|
||||
repo: "Repo", output_filter: FilterFn = None, deps: bool = False
|
||||
) -> Outputs:
|
||||
outs = [
|
||||
out
|
||||
for stage in repo.graph # using `graph` to ensure graph checks run
|
||||
for out in (stage.deps if deps else stage.outs)
|
||||
]
|
||||
return list(filter(output_filter, outs)) if output_filter else outs
|
||||
index = repo.index
|
||||
index.check_graph() # ensure graph is correct
|
||||
return list(filter(output_filter, index.deps if deps else index.outs))
|
||||
|
||||
|
||||
def _collect_paths(
|
||||
@@ -46,15 +43,12 @@ def _collect_paths(
|
||||
target_infos.extend(repo.dvcignore.walk_files(fs, path_info))
|
||||
|
||||
if not fs.exists(path_info):
|
||||
if not recursive:
|
||||
if rev == "workspace" or rev == "":
|
||||
logger.warning(
|
||||
"'%s' was not found in current workspace.", path_info
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"'%s' was not found at: '%s'.", path_info, rev
|
||||
)
|
||||
if rev == "workspace" or rev == "":
|
||||
logger.warning(
|
||||
"'%s' was not found in current workspace.", path_info
|
||||
)
|
||||
else:
|
||||
logger.warning("'%s' was not found at: '%s'.", path_info, rev)
|
||||
continue
|
||||
target_infos.append(path_info)
|
||||
return target_infos
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ from . import locked
|
||||
|
||||
@locked
|
||||
def _destroy_stages(repo):
|
||||
for stage in repo.stages:
|
||||
for stage in repo.index.stages:
|
||||
stage.unprotect_outs()
|
||||
stage.dvcfile.remove(force=True)
|
||||
|
||||
|
||||
+19
-16
@@ -5,7 +5,6 @@ from typing import Dict, List
|
||||
|
||||
from dvc.exceptions import PathMissingError
|
||||
from dvc.objects.stage import get_file_hash
|
||||
from dvc.objects.stage import stage as ostage
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.experiments.utils import fix_exp_head
|
||||
|
||||
@@ -116,6 +115,7 @@ def _paths_checksums(repo, repo_fs, targets):
|
||||
|
||||
def _output_paths(repo, repo_fs, targets):
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
from dvc.objects.stage import stage as ostage
|
||||
|
||||
on_working_fs = isinstance(repo.fs, LocalFileSystem)
|
||||
|
||||
@@ -134,25 +134,28 @@ def _output_paths(repo, repo_fs, targets):
|
||||
def _to_checksum(output):
|
||||
if on_working_fs:
|
||||
return ostage(
|
||||
repo.odb.local, output.path_info, repo.odb.local.fs, "md5"
|
||||
).hash_info.value
|
||||
repo.odb.local,
|
||||
output.path_info,
|
||||
repo.odb.local.fs,
|
||||
"md5",
|
||||
dry_run=True,
|
||||
)[1].hash_info.value
|
||||
return output.hash_info.value
|
||||
|
||||
for stage in repo.stages:
|
||||
for output in stage.outs:
|
||||
if _exists(output):
|
||||
yield_output = targets is None or any(
|
||||
output.path_info.isin_or_eq(target) for target in targets
|
||||
)
|
||||
for output in repo.index.outs:
|
||||
if _exists(output):
|
||||
yield_output = targets is None or any(
|
||||
output.path_info.isin_or_eq(target) for target in targets
|
||||
)
|
||||
|
||||
if yield_output:
|
||||
yield _to_path(output), _to_checksum(output)
|
||||
if yield_output:
|
||||
yield _to_path(output), _to_checksum(output)
|
||||
|
||||
if output.is_dir_checksum and (
|
||||
yield_output
|
||||
or any(target.isin(output.path_info) for target in targets)
|
||||
):
|
||||
yield from _dir_output_paths(repo_fs, output, targets)
|
||||
if output.is_dir_checksum and (
|
||||
yield_output
|
||||
or any(target.isin(output.path_info) for target in targets)
|
||||
):
|
||||
yield from _dir_output_paths(repo_fs, output, targets)
|
||||
|
||||
|
||||
def _dir_output_paths(repo_fs, output, targets=None):
|
||||
|
||||
@@ -81,6 +81,7 @@ class Experiments:
|
||||
r"(?P<checkpoint>-checkpoint)?$"
|
||||
)
|
||||
EXEC_TMP_DIR = "exps"
|
||||
EXEC_PID_DIR = os.path.join(EXEC_TMP_DIR, "run")
|
||||
|
||||
StashEntry = namedtuple(
|
||||
"StashEntry", ["index", "rev", "baseline_rev", "branch", "name"]
|
||||
@@ -351,8 +352,7 @@ class Experiments:
|
||||
|
||||
def _update_params(self, params: dict):
|
||||
"""Update experiment params files with the specified values."""
|
||||
from benedict import benedict
|
||||
|
||||
from dvc.utils.collections import merge_params
|
||||
from dvc.utils.serialize import MODIFIERS
|
||||
|
||||
logger.debug("Using experiment params '%s'", params)
|
||||
@@ -362,7 +362,7 @@ class Experiments:
|
||||
suffix = path.suffix.lower()
|
||||
modify_data = MODIFIERS[suffix]
|
||||
with modify_data(path, fs=self.repo.fs) as data:
|
||||
benedict(data).merge(params[params_fname], overwrite=True)
|
||||
merge_params(data, params[params_fname])
|
||||
|
||||
# Force params file changes to be staged in git
|
||||
# Otherwise in certain situations the changes to params file may be
|
||||
@@ -455,7 +455,7 @@ class Experiments:
|
||||
)
|
||||
logger.info(
|
||||
"\nTo promote an experiment to a Git branch run:\n\n"
|
||||
"\tdvc exp branch <exp>\n"
|
||||
"\tdvc exp branch <exp> <branch>\n"
|
||||
)
|
||||
|
||||
@scm_locked
|
||||
@@ -584,30 +584,25 @@ class Experiments:
|
||||
|
||||
logger.debug(
|
||||
"Reproducing experiment revs '%s'",
|
||||
", ".join((rev[:7] for rev in to_run)),
|
||||
", ".join(rev[:7] for rev in to_run),
|
||||
)
|
||||
|
||||
executors = self._init_executors(to_run)
|
||||
exec_results = self._executors_repro(executors, **kwargs)
|
||||
|
||||
if keep_stash:
|
||||
try:
|
||||
exec_results = {}
|
||||
exec_results.update(self._executors_repro(executors, **kwargs))
|
||||
finally:
|
||||
# only drop successfully run stashed experiments
|
||||
to_drop = sorted(
|
||||
(
|
||||
stash_revs[rev][0]
|
||||
for rev in exec_results
|
||||
if rev in stash_revs
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
else:
|
||||
# drop all stashed experiments
|
||||
to_drop = sorted(
|
||||
(stash_revs[rev][0] for rev in to_run if rev in stash_revs),
|
||||
reverse=True,
|
||||
)
|
||||
for index in to_drop:
|
||||
self.stash.drop(index)
|
||||
to_drop = [
|
||||
entry.index
|
||||
for rev, entry in to_run.items()
|
||||
if (
|
||||
entry.index is not None
|
||||
and (not keep_stash or rev in exec_results)
|
||||
)
|
||||
]
|
||||
for index in sorted(to_drop, reverse=True):
|
||||
self.stash.drop(index)
|
||||
|
||||
result: Dict[str, str] = {}
|
||||
for _, exp_result in exec_results.items():
|
||||
@@ -623,6 +618,9 @@ class Experiments:
|
||||
base_tmp_dir = os.path.join(self.repo.tmp_dir, self.EXEC_TMP_DIR)
|
||||
if not os.path.exists(base_tmp_dir):
|
||||
makedirs(base_tmp_dir)
|
||||
pid_dir = os.path.join(self.repo.tmp_dir, self.EXEC_PID_DIR)
|
||||
if not os.path.exists(pid_dir):
|
||||
makedirs(pid_dir)
|
||||
for stash_rev, item in to_run.items():
|
||||
self.scm.set_ref(EXEC_HEAD, item.rev)
|
||||
self.scm.set_ref(EXEC_MERGE, stash_rev)
|
||||
@@ -649,9 +647,10 @@ class Experiments:
|
||||
|
||||
return executors
|
||||
|
||||
@unlocked_repo
|
||||
def _executors_repro(
|
||||
self, executors: dict, jobs: Optional[int] = 1
|
||||
) -> Mapping[str, Mapping[str, str]]:
|
||||
) -> Dict[str, Dict[str, str]]:
|
||||
"""Run dvc repro for the specified BaseExecutors in parallel.
|
||||
|
||||
Returns:
|
||||
@@ -667,6 +666,11 @@ class Experiments:
|
||||
with ProcessPoolExecutor(max_workers=jobs) as workers:
|
||||
futures = {}
|
||||
for rev, executor in executors.items():
|
||||
pidfile = os.path.join(
|
||||
self.repo.tmp_dir,
|
||||
self.EXEC_PID_DIR,
|
||||
f"{rev}{executor.PIDFILE_EXT}",
|
||||
)
|
||||
future = workers.submit(
|
||||
executor.reproduce,
|
||||
executor.dvc_dir,
|
||||
@@ -675,6 +679,8 @@ class Experiments:
|
||||
name=executor.name,
|
||||
rel_cwd=rel_cwd,
|
||||
log_level=logger.getEffectiveLevel(),
|
||||
pidfile=pidfile,
|
||||
git_url=executor.git_url,
|
||||
)
|
||||
futures[future] = (rev, executor)
|
||||
|
||||
@@ -683,12 +689,15 @@ class Experiments:
|
||||
except KeyboardInterrupt:
|
||||
# forward SIGINT to any running executor processes and
|
||||
# cancel any remaining futures
|
||||
workers.shutdown(wait=False)
|
||||
pids = {}
|
||||
while not pid_q.empty():
|
||||
rev, pid = pid_q.get()
|
||||
pids[rev] = pid
|
||||
for future, (rev, _) in futures.items():
|
||||
if future.running():
|
||||
# if future has already been started by the scheduler
|
||||
# we still have to wait until it tells us its PID
|
||||
while rev not in pids:
|
||||
rev, pid = pid_q.get()
|
||||
pids[rev] = pid
|
||||
os.kill(pids[rev], signal.SIGINT)
|
||||
elif not future.done():
|
||||
future.cancel()
|
||||
@@ -735,7 +744,10 @@ class Experiments:
|
||||
raise ExperimentExistsError(ref_info.name)
|
||||
|
||||
for ref in executor.fetch_exps(
|
||||
self.scm, force=exec_result.force, on_diverged=on_diverged
|
||||
self.scm,
|
||||
executor.git_url,
|
||||
force=exec_result.force,
|
||||
on_diverged=on_diverged,
|
||||
):
|
||||
exp_rev = self.scm.get_ref(ref)
|
||||
if exp_rev:
|
||||
@@ -747,6 +759,8 @@ class Experiments:
|
||||
@unlocked_repo
|
||||
def _workspace_repro(self) -> Mapping[str, str]:
|
||||
"""Run the most recently stashed experiment in the workspace."""
|
||||
from dvc.utils.fs import makedirs
|
||||
|
||||
from .executor.base import BaseExecutor
|
||||
|
||||
entry = first(self.stash_revs.values())
|
||||
@@ -766,12 +780,19 @@ class Experiments:
|
||||
self.scm.remove_ref(EXEC_BRANCH)
|
||||
try:
|
||||
orig_checkpoint = self.scm.get_ref(EXEC_CHECKPOINT)
|
||||
pid_dir = os.path.join(self.repo.tmp_dir, self.EXEC_PID_DIR)
|
||||
if not os.path.exists(pid_dir):
|
||||
makedirs(pid_dir)
|
||||
pidfile = os.path.join(
|
||||
pid_dir, f"workspace{BaseExecutor.PIDFILE_EXT}"
|
||||
)
|
||||
exec_result = BaseExecutor.reproduce(
|
||||
None,
|
||||
rev,
|
||||
name=entry.name,
|
||||
rel_cwd=relpath(os.getcwd(), self.scm.root_dir),
|
||||
log_errors=False,
|
||||
pidfile=pidfile,
|
||||
)
|
||||
|
||||
if not exec_result.exp_hash:
|
||||
@@ -858,9 +879,58 @@ class Experiments:
|
||||
exclude = f"{EXEC_NAMESPACE}/*"
|
||||
ref = self.scm.describe(rev, base=EXPS_NAMESPACE, exclude=exclude)
|
||||
if ref:
|
||||
return ExpRefInfo.from_ref(ref).name
|
||||
name = ExpRefInfo.from_ref(ref).name
|
||||
if name:
|
||||
return name
|
||||
if rev in self.stash_revs:
|
||||
return self.stash_revs[rev].name
|
||||
return None
|
||||
|
||||
def get_running_exps(self) -> Dict[str, int]:
|
||||
"""Return info for running experiments."""
|
||||
from dvc.utils.serialize import load_yaml
|
||||
|
||||
from .executor.base import BaseExecutor, ExecutorInfo
|
||||
|
||||
result = {}
|
||||
for pidfile in self.repo.fs.walk_files(
|
||||
os.path.join(self.repo.tmp_dir, self.EXEC_PID_DIR)
|
||||
):
|
||||
rev, _ = os.path.splitext(os.path.basename(pidfile))
|
||||
|
||||
try:
|
||||
info = ExecutorInfo.from_dict(load_yaml(pidfile))
|
||||
if rev == "workspace":
|
||||
# If we are appending to a checkpoint branch in a workspace
|
||||
# run, show the latest checkpoint as running.
|
||||
last_rev = self.scm.get_ref(EXEC_BRANCH)
|
||||
if last_rev:
|
||||
result[last_rev] = info.to_dict()
|
||||
else:
|
||||
result[rev] = info.to_dict()
|
||||
else:
|
||||
result[rev] = info.to_dict()
|
||||
if info.git_url:
|
||||
|
||||
def on_diverged(_ref: str, _checkpoint: bool):
|
||||
return False
|
||||
|
||||
for ref in BaseExecutor.fetch_exps(
|
||||
self.scm,
|
||||
info.git_url,
|
||||
on_diverged=on_diverged,
|
||||
):
|
||||
logger.debug(
|
||||
"Updated running experiment '%s'.", ref
|
||||
)
|
||||
last_rev = self.scm.get_ref(ref)
|
||||
result[rev]["last"] = last_rev
|
||||
if last_rev:
|
||||
result[last_rev] = info.to_dict()
|
||||
except OSError:
|
||||
pass
|
||||
return result
|
||||
|
||||
def apply(self, *args, **kwargs):
|
||||
from dvc.repo.experiments.apply import apply
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ def diff(repo, *args, a_rev=None, b_rev=None, param_deps=False, **kwargs):
|
||||
|
||||
return {
|
||||
key: _diff(
|
||||
format_dict(old[key]),
|
||||
format_dict(new[key]),
|
||||
format_dict(old.get("data", {}).get(key, {})),
|
||||
format_dict(new.get("data", {}).get(key, {})),
|
||||
with_unchanged=with_unchanged,
|
||||
)
|
||||
for key in ["metrics", "params"]
|
||||
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
import pickle
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
@@ -15,8 +16,10 @@ from typing import (
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
from dvc.env import DVC_EXP_AUTO_PUSH, DVC_EXP_GIT_REMOTE
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.repo import Repo
|
||||
from dvc.repo.experiments.base import (
|
||||
EXEC_BASELINE,
|
||||
EXEC_BRANCH,
|
||||
@@ -35,7 +38,7 @@ from dvc.scm import SCM
|
||||
from dvc.stage import PipelineStage
|
||||
from dvc.stage.monitor import CheckpointKilledError
|
||||
from dvc.stage.serialize import to_lockfile
|
||||
from dvc.utils import dict_sha256
|
||||
from dvc.utils import dict_sha256, env2bool
|
||||
from dvc.utils.fs import remove
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -52,6 +55,36 @@ class ExecutorResult(NamedTuple):
|
||||
force: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutorInfo:
|
||||
PARAM_PID = "pid"
|
||||
PARAM_GIT_URL = "git"
|
||||
PARAM_BASELINE_REV = "baseline"
|
||||
PARAM_LOCATION = "location"
|
||||
|
||||
pid: Optional[int]
|
||||
git_url: Optional[str]
|
||||
baseline_rev: Optional[str]
|
||||
location: Optional[str]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
return cls(
|
||||
d.get(cls.PARAM_PID),
|
||||
d.get(cls.PARAM_GIT_URL),
|
||||
d.get(cls.PARAM_BASELINE_REV),
|
||||
d.get(cls.PARAM_LOCATION),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
self.PARAM_PID: self.pid,
|
||||
self.PARAM_GIT_URL: self.git_url,
|
||||
self.PARAM_BASELINE_REV: self.baseline_rev,
|
||||
self.PARAM_LOCATION: self.location,
|
||||
}
|
||||
|
||||
|
||||
class BaseExecutor(ABC):
|
||||
"""Base class for executing experiments in parallel.
|
||||
|
||||
@@ -66,6 +99,8 @@ class BaseExecutor(ABC):
|
||||
PACKED_ARGS_FILE = "repro.dat"
|
||||
WARN_UNTRACKED = False
|
||||
QUIET = False
|
||||
PIDFILE_EXT = ".run"
|
||||
DEFAULT_LOCATION: Optional[str] = "workspace"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -161,9 +196,11 @@ class BaseExecutor(ABC):
|
||||
data = pickle.load(fobj)
|
||||
return data["args"], data["kwargs"]
|
||||
|
||||
@classmethod
|
||||
def fetch_exps(
|
||||
self,
|
||||
cls,
|
||||
dest_scm: "Git",
|
||||
url: str,
|
||||
force: bool = False,
|
||||
on_diverged: Callable[[str, bool], None] = None,
|
||||
) -> Iterable[str]:
|
||||
@@ -171,13 +208,17 @@ class BaseExecutor(ABC):
|
||||
|
||||
Args:
|
||||
dest_scm: Destination Git instance.
|
||||
url: Git remote URL to fetch from.
|
||||
force: If True, diverged refs will be overwritten
|
||||
on_diverged: Callback in the form on_diverged(ref, is_checkpoint)
|
||||
to be called when an experiment ref has diverged.
|
||||
"""
|
||||
refs = []
|
||||
for ref in self.scm.iter_refs(base=EXPS_NAMESPACE):
|
||||
if not ref.startswith(EXEC_NAMESPACE) and ref != EXPS_STASH:
|
||||
has_checkpoint = False
|
||||
for ref in dest_scm.iter_remote_refs(url, base=EXPS_NAMESPACE):
|
||||
if ref == EXEC_CHECKPOINT:
|
||||
has_checkpoint = True
|
||||
elif not ref.startswith(EXEC_NAMESPACE) and ref != EXPS_STASH:
|
||||
refs.append(ref)
|
||||
|
||||
def on_diverged_ref(orig_ref: str, new_rev: str):
|
||||
@@ -185,29 +226,43 @@ class BaseExecutor(ABC):
|
||||
logger.debug("Replacing existing experiment '%s'", orig_ref)
|
||||
return True
|
||||
|
||||
checkpoint = self.scm.get_ref(EXEC_CHECKPOINT) is not None
|
||||
self._raise_ref_conflict(dest_scm, orig_ref, new_rev, checkpoint)
|
||||
cls._raise_ref_conflict(
|
||||
dest_scm, orig_ref, new_rev, has_checkpoint
|
||||
)
|
||||
if on_diverged:
|
||||
on_diverged(orig_ref, checkpoint)
|
||||
on_diverged(orig_ref, has_checkpoint)
|
||||
logger.debug("Reproduced existing experiment '%s'", orig_ref)
|
||||
return False
|
||||
|
||||
# fetch experiments
|
||||
dest_scm.fetch_refspecs(
|
||||
self.git_url,
|
||||
url,
|
||||
[f"{ref}:{ref}" for ref in refs],
|
||||
on_diverged=on_diverged_ref,
|
||||
force=force,
|
||||
)
|
||||
# update last run checkpoint (if it exists)
|
||||
if self.scm.get_ref(EXEC_CHECKPOINT):
|
||||
if has_checkpoint:
|
||||
dest_scm.fetch_refspecs(
|
||||
self.git_url,
|
||||
url,
|
||||
[f"{EXEC_CHECKPOINT}:{EXEC_CHECKPOINT}"],
|
||||
force=True,
|
||||
)
|
||||
return refs
|
||||
|
||||
@classmethod
|
||||
def _validate_remotes(cls, dvc: "Repo", git_remote: Optional[str]):
|
||||
|
||||
if git_remote == dvc.root_dir:
|
||||
logger.warning(
|
||||
f"'{git_remote}' points to the current Git repo, experiment "
|
||||
"Git refs will not be pushed. But DVC cache and run cache "
|
||||
"will automatically be pushed to the default DVC remote "
|
||||
"(if any) on each experiment commit."
|
||||
)
|
||||
dvc.scm.validate_git_remote(git_remote)
|
||||
dvc.cloud.get_remote_odb()
|
||||
|
||||
@classmethod
|
||||
def reproduce(
|
||||
cls,
|
||||
@@ -218,6 +273,7 @@ class BaseExecutor(ABC):
|
||||
name: Optional[str] = None,
|
||||
log_errors: bool = True,
|
||||
log_level: Optional[int] = None,
|
||||
**kwargs,
|
||||
) -> "ExecutorResult":
|
||||
"""Run dvc repro and return the result.
|
||||
|
||||
@@ -229,6 +285,9 @@ class BaseExecutor(ABC):
|
||||
from dvc.repo.checkout import checkout as dvc_checkout
|
||||
from dvc.repo.reproduce import reproduce as dvc_reproduce
|
||||
|
||||
auto_push = env2bool(DVC_EXP_AUTO_PUSH)
|
||||
git_remote = os.getenv(DVC_EXP_GIT_REMOTE, None)
|
||||
|
||||
unchanged = []
|
||||
|
||||
if queue is not None:
|
||||
@@ -245,7 +304,15 @@ class BaseExecutor(ABC):
|
||||
exp_ref: Optional["ExpRefInfo"] = None
|
||||
repro_force: bool = False
|
||||
|
||||
with cls._repro_dvc(dvc_dir, rel_cwd, log_errors) as dvc:
|
||||
with cls._repro_dvc(
|
||||
dvc_dir,
|
||||
rel_cwd,
|
||||
log_errors,
|
||||
**kwargs,
|
||||
) as dvc:
|
||||
if auto_push:
|
||||
cls._validate_remotes(dvc, git_remote)
|
||||
|
||||
args, kwargs = cls._repro_args(dvc)
|
||||
if args:
|
||||
targets: Optional[Union[list, str]] = args[0]
|
||||
@@ -281,10 +348,12 @@ class BaseExecutor(ABC):
|
||||
quiet=True,
|
||||
allow_missing=True,
|
||||
checkpoint_reset=checkpoint_reset,
|
||||
recursive=kwargs.get("recursive", False),
|
||||
)
|
||||
|
||||
checkpoint_func = partial(
|
||||
cls.checkpoint_callback,
|
||||
dvc,
|
||||
dvc.scm,
|
||||
name,
|
||||
repro_force or checkpoint_reset,
|
||||
@@ -315,6 +384,8 @@ class BaseExecutor(ABC):
|
||||
force=repro_force,
|
||||
checkpoint=is_checkpoint,
|
||||
)
|
||||
if auto_push:
|
||||
cls._auto_push(dvc, dvc.scm, git_remote)
|
||||
except UnchangedExperimentError:
|
||||
pass
|
||||
ref = dvc.scm.get_ref(EXEC_BRANCH, follow=False)
|
||||
@@ -339,9 +410,15 @@ class BaseExecutor(ABC):
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def _repro_dvc(
|
||||
cls, dvc_dir: Optional[str], rel_cwd: Optional[str], log_errors: bool
|
||||
cls,
|
||||
dvc_dir: Optional[str],
|
||||
rel_cwd: Optional[str],
|
||||
log_errors: bool,
|
||||
pidfile: Optional[str] = None,
|
||||
git_url: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
from dvc.repo import Repo
|
||||
from dvc.utils.serialize import modify_yaml
|
||||
|
||||
dvc = Repo(dvc_dir)
|
||||
if cls.QUIET:
|
||||
@@ -354,6 +431,15 @@ class BaseExecutor(ABC):
|
||||
os.chdir(dvc.root_dir)
|
||||
else:
|
||||
old_cwd = None
|
||||
if pidfile is not None:
|
||||
info = ExecutorInfo(
|
||||
os.getpid(),
|
||||
git_url,
|
||||
dvc.scm.get_rev(),
|
||||
cls.DEFAULT_LOCATION,
|
||||
)
|
||||
with modify_yaml(pidfile) as d:
|
||||
d.update(info.to_dict())
|
||||
logger.debug("Running repro in '%s'", os.getcwd())
|
||||
|
||||
try:
|
||||
@@ -369,6 +455,8 @@ class BaseExecutor(ABC):
|
||||
logger.exception("unexpected error")
|
||||
raise
|
||||
finally:
|
||||
if pidfile is not None:
|
||||
remove(pidfile)
|
||||
dvc.close()
|
||||
if old_cwd:
|
||||
os.chdir(old_cwd)
|
||||
@@ -386,9 +474,32 @@ class BaseExecutor(ABC):
|
||||
kwargs = {}
|
||||
return args, kwargs
|
||||
|
||||
@staticmethod
|
||||
def _auto_push(
|
||||
dvc: "Repo",
|
||||
scm: "Git",
|
||||
git_remote: Optional[str],
|
||||
push_cache=True,
|
||||
run_cache=True,
|
||||
):
|
||||
branch = scm.get_ref(EXEC_BRANCH, follow=False)
|
||||
try:
|
||||
dvc.experiments.push(
|
||||
git_remote,
|
||||
branch,
|
||||
push_cache=push_cache,
|
||||
run_cache=run_cache,
|
||||
)
|
||||
except BaseException as exc:
|
||||
logger.warning(
|
||||
"Something went wrong while auto pushing experiment "
|
||||
f"to the remote '{git_remote}': {exc}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def checkpoint_callback(
|
||||
cls,
|
||||
dvc: "Repo",
|
||||
scm: "Git",
|
||||
name: Optional[str],
|
||||
force: bool,
|
||||
@@ -400,6 +511,10 @@ class BaseExecutor(ABC):
|
||||
exp_rev = cls.commit(
|
||||
scm, exp_hash, exp_name=name, force=force, checkpoint=True
|
||||
)
|
||||
|
||||
if env2bool(DVC_EXP_AUTO_PUSH):
|
||||
git_remote = os.getenv(DVC_EXP_GIT_REMOTE)
|
||||
cls._auto_push(dvc, scm, git_remote)
|
||||
logger.info("Checkpoint experiment iteration '%s'.", exp_rev[:7])
|
||||
except UnchangedExperimentError:
|
||||
pass
|
||||
|
||||
@@ -45,6 +45,7 @@ class TempDirExecutor(BaseLocalExecutor):
|
||||
# suggestions) that are not applicable outside of workspace runs
|
||||
WARN_UNTRACKED = True
|
||||
QUIET = True
|
||||
DEFAULT_LOCATION: Optional[str] = "temp"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
|
||||
from dvc.exceptions import DvcException, InvalidArgumentError
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.experiments.base import ExpRefInfo
|
||||
from dvc.repo.scm_context import scm_context
|
||||
|
||||
from .utils import exp_commits, exp_refs_by_name
|
||||
@@ -12,7 +13,13 @@ logger = logging.getLogger(__name__)
|
||||
@locked
|
||||
@scm_context
|
||||
def push(
|
||||
repo, git_remote, exp_name, *args, force=False, push_cache=False, **kwargs
|
||||
repo,
|
||||
git_remote,
|
||||
exp_name: str,
|
||||
*args,
|
||||
force=False,
|
||||
push_cache=False,
|
||||
**kwargs,
|
||||
):
|
||||
exp_ref = _get_exp_ref(repo, exp_name)
|
||||
|
||||
@@ -35,9 +42,9 @@ def push(
|
||||
_push_cache(repo, exp_ref, **kwargs)
|
||||
|
||||
|
||||
def _get_exp_ref(repo, exp_name):
|
||||
def _get_exp_ref(repo, exp_name: str) -> ExpRefInfo:
|
||||
if exp_name.startswith("refs/"):
|
||||
return exp_name
|
||||
return ExpRefInfo.from_ref(exp_name)
|
||||
|
||||
exp_refs = list(exp_refs_by_name(repo.scm, exp_name))
|
||||
if not exp_refs:
|
||||
|
||||
@@ -1,52 +1,85 @@
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.scm_context import scm_context
|
||||
from dvc.scm.base import RevError
|
||||
|
||||
from .base import EXPS_NAMESPACE, ExpRefInfo
|
||||
from .utils import exp_refs_by_name, remove_exp_refs
|
||||
from .utils import exp_refs, exp_refs_by_name, remove_exp_refs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@locked
|
||||
@scm_context
|
||||
def remove(repo, exp_names=None, queue=False, **kwargs):
|
||||
if not exp_names and not queue:
|
||||
def remove(
|
||||
repo,
|
||||
exp_names=None,
|
||||
queue=False,
|
||||
clear_all=False,
|
||||
**kwargs,
|
||||
):
|
||||
if not any([exp_names, queue, clear_all]):
|
||||
return 0
|
||||
|
||||
removed = 0
|
||||
if queue:
|
||||
removed += len(repo.experiments.stash)
|
||||
repo.experiments.stash.clear()
|
||||
removed += _clear_stash(repo)
|
||||
if clear_all:
|
||||
removed += _clear_all(repo)
|
||||
|
||||
if exp_names:
|
||||
ref_infos = list(_get_exp_refs(repo, exp_names))
|
||||
remove_exp_refs(repo.scm, ref_infos)
|
||||
removed += len(ref_infos)
|
||||
remained = _remove_commited_exps(repo, exp_names)
|
||||
remained = _remove_queued_exps(repo, remained)
|
||||
if remained:
|
||||
raise InvalidArgumentError(
|
||||
"'{}' is not a valid experiment".format(";".join(remained))
|
||||
)
|
||||
removed += len(exp_names) - len(remained)
|
||||
return removed
|
||||
|
||||
|
||||
def _get_exp_refs(repo, exp_names):
|
||||
def _clear_stash(repo):
|
||||
removed = len(repo.experiments.stash)
|
||||
repo.experiments.stash.clear()
|
||||
return removed
|
||||
|
||||
|
||||
def _clear_all(repo):
|
||||
ref_infos = list(exp_refs(repo.scm))
|
||||
remove_exp_refs(repo.scm, ref_infos)
|
||||
return len(ref_infos)
|
||||
|
||||
|
||||
def _get_exp_stash_index(repo, ref_or_rev: str) -> Optional[int]:
|
||||
stash_revs = repo.experiments.stash_revs
|
||||
for _, ref_info in stash_revs.items():
|
||||
if ref_info.name == ref_or_rev:
|
||||
return ref_info.index
|
||||
try:
|
||||
rev = repo.scm.resolve_rev(ref_or_rev)
|
||||
if rev in stash_revs:
|
||||
return stash_revs.get(rev).index
|
||||
except RevError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_exp_ref(repo, exp_name: str) -> Optional[ExpRefInfo]:
|
||||
cur_rev = repo.scm.get_rev()
|
||||
for name in exp_names:
|
||||
if name.startswith(EXPS_NAMESPACE):
|
||||
if not repo.scm.get_ref(name):
|
||||
raise InvalidArgumentError(
|
||||
f"'{name}' is not a valid experiment name"
|
||||
)
|
||||
yield ExpRefInfo.from_ref(name)
|
||||
else:
|
||||
|
||||
exp_refs = list(exp_refs_by_name(repo.scm, name))
|
||||
if not exp_refs:
|
||||
raise InvalidArgumentError(
|
||||
f"'{name}' is not a valid experiment name"
|
||||
)
|
||||
yield _get_ref(exp_refs, name, cur_rev)
|
||||
if exp_name.startswith(EXPS_NAMESPACE):
|
||||
if repo.scm.get_ref(exp_name):
|
||||
return ExpRefInfo.from_ref(exp_name)
|
||||
else:
|
||||
exp_ref_list = list(exp_refs_by_name(repo.scm, exp_name))
|
||||
if exp_ref_list:
|
||||
return _get_ref(exp_ref_list, exp_name, cur_rev)
|
||||
return None
|
||||
|
||||
|
||||
def _get_ref(ref_infos, name, cur_rev):
|
||||
def _get_ref(ref_infos, name, cur_rev) -> Optional[ExpRefInfo]:
|
||||
if len(ref_infos) > 1:
|
||||
for info in ref_infos:
|
||||
if info.baseline_sha == cur_rev:
|
||||
@@ -61,3 +94,28 @@ def _get_ref(ref_infos, name, cur_rev):
|
||||
msg.extend([f"\t{info}" for info in ref_infos])
|
||||
raise InvalidArgumentError("\n".join(msg))
|
||||
return ref_infos[0]
|
||||
|
||||
|
||||
def _remove_commited_exps(repo, refs: List[str]) -> List[str]:
|
||||
remain_list = []
|
||||
remove_list = []
|
||||
for ref in refs:
|
||||
ref_info = _get_exp_ref(repo, ref)
|
||||
if ref_info:
|
||||
remove_list.append(ref_info)
|
||||
else:
|
||||
remain_list.append(ref)
|
||||
if remove_list:
|
||||
remove_exp_refs(repo.scm, remove_list)
|
||||
return remain_list
|
||||
|
||||
|
||||
def _remove_queued_exps(repo, refs_or_revs: List[str]) -> List[str]:
|
||||
remain_list = []
|
||||
for ref_or_rev in refs_or_revs:
|
||||
stash_index = _get_exp_stash_index(repo, ref_or_rev)
|
||||
if stash_index is None:
|
||||
remain_list.append(ref_or_rev)
|
||||
else:
|
||||
repo.experiments.stash.drop(stash_index)
|
||||
return remain_list
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import logging
|
||||
from collections import OrderedDict, defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.experiments.base import ExpRefInfo
|
||||
from dvc.repo.experiments.executor.base import ExecutorInfo
|
||||
from dvc.repo.experiments.utils import fix_exp_head
|
||||
from dvc.repo.metrics.show import _collect_metrics, _read_metrics
|
||||
from dvc.repo.params.show import _collect_configs, _read_params
|
||||
from dvc.repo.metrics.show import _gather_metrics
|
||||
from dvc.repo.params.show import _gather_params
|
||||
from dvc.scm.base import SCMError
|
||||
from dvc.utils import error_handler, onerror_collect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
def _collect_experiment_commit(
|
||||
repo, exp_rev, stash=False, sha_only=True, param_deps=False
|
||||
repo,
|
||||
exp_rev,
|
||||
stash=False,
|
||||
sha_only=True,
|
||||
param_deps=False,
|
||||
running=None,
|
||||
onerror: Optional[Callable] = None,
|
||||
):
|
||||
res = defaultdict(dict)
|
||||
res: Dict[str, Optional[Any]] = defaultdict(dict)
|
||||
for rev in repo.brancher(revs=[exp_rev]):
|
||||
if rev == "workspace":
|
||||
if exp_rev != "workspace":
|
||||
@@ -26,17 +36,23 @@ def _collect_experiment_commit(
|
||||
commit = repo.scm.resolve_commit(rev)
|
||||
res["timestamp"] = datetime.fromtimestamp(commit.commit_time)
|
||||
|
||||
params, params_path_infos = _collect_configs(repo, rev=rev)
|
||||
params = _read_params(
|
||||
repo, params, params_path_infos, rev, deps=param_deps
|
||||
params = _gather_params(
|
||||
repo, rev=rev, targets=None, deps=param_deps, onerror=onerror
|
||||
)
|
||||
if params:
|
||||
res["params"] = params
|
||||
|
||||
res["queued"] = stash
|
||||
if running is not None and exp_rev in running:
|
||||
res["running"] = True
|
||||
res["executor"] = running[exp_rev].get(ExecutorInfo.PARAM_LOCATION)
|
||||
else:
|
||||
res["running"] = False
|
||||
res["executor"] = None
|
||||
if not stash:
|
||||
metrics = _collect_metrics(repo, None, rev, False)
|
||||
vals = _read_metrics(repo, metrics, rev)
|
||||
vals = _gather_metrics(
|
||||
repo, targets=None, rev=rev, recursive=False, onerror=onerror
|
||||
)
|
||||
res["metrics"] = vals
|
||||
|
||||
if not sha_only and rev != "workspace":
|
||||
@@ -45,10 +61,7 @@ def _collect_experiment_commit(
|
||||
if name:
|
||||
break
|
||||
if not name:
|
||||
if stash:
|
||||
pass
|
||||
else:
|
||||
name = repo.experiments.get_exact_name(rev)
|
||||
name = repo.experiments.get_exact_name(rev)
|
||||
if name:
|
||||
name = name.rsplit("/")[-1]
|
||||
res["name"] = name
|
||||
@@ -56,28 +69,34 @@ def _collect_experiment_commit(
|
||||
return res
|
||||
|
||||
|
||||
def _collect_experiment_branch(res, repo, branch, baseline, **kwargs):
|
||||
def _collect_experiment_branch(
|
||||
res, repo, branch, baseline, onerror: Optional[Callable] = None, **kwargs
|
||||
):
|
||||
exp_rev = repo.scm.resolve_rev(branch)
|
||||
prev = None
|
||||
revs = list(repo.scm.branch_revs(exp_rev, baseline))
|
||||
for rev in revs:
|
||||
collected_exp = _collect_experiment_commit(repo, rev, **kwargs)
|
||||
collected_exp = _collect_experiment_commit(
|
||||
repo, rev, onerror=onerror, **kwargs
|
||||
)
|
||||
if len(revs) > 1:
|
||||
exp = {"checkpoint_tip": exp_rev}
|
||||
if prev:
|
||||
res[prev]["checkpoint_parent"] = rev
|
||||
res[prev]["data"][ # type: ignore[unreachable]
|
||||
"checkpoint_parent"
|
||||
] = rev
|
||||
if rev in res:
|
||||
res[rev].update(exp)
|
||||
res[rev]["data"].update(exp)
|
||||
res.move_to_end(rev)
|
||||
else:
|
||||
exp.update(collected_exp)
|
||||
exp.update(collected_exp["data"])
|
||||
else:
|
||||
exp = collected_exp
|
||||
exp = collected_exp["data"]
|
||||
if rev not in res:
|
||||
res[rev] = exp
|
||||
res[rev] = {"data": exp}
|
||||
prev = rev
|
||||
if len(revs) > 1:
|
||||
res[prev]["checkpoint_parent"] = baseline
|
||||
res[prev]["data"]["checkpoint_parent"] = baseline
|
||||
return res
|
||||
|
||||
|
||||
@@ -91,8 +110,12 @@ def show(
|
||||
sha_only=False,
|
||||
num=1,
|
||||
param_deps=False,
|
||||
onerror: Optional[Callable] = None,
|
||||
):
|
||||
res = defaultdict(OrderedDict)
|
||||
if onerror is None:
|
||||
onerror = onerror_collect
|
||||
|
||||
res: Dict[str, Dict] = defaultdict(OrderedDict)
|
||||
|
||||
if num < 1:
|
||||
raise InvalidArgumentError(f"Invalid number of commits '{num}'")
|
||||
@@ -117,9 +140,16 @@ def show(
|
||||
)
|
||||
)
|
||||
|
||||
running = repo.experiments.get_running_exps()
|
||||
|
||||
for rev in revs:
|
||||
res[rev]["baseline"] = _collect_experiment_commit(
|
||||
repo, rev, sha_only=sha_only, param_deps=param_deps
|
||||
repo,
|
||||
rev,
|
||||
sha_only=sha_only,
|
||||
param_deps=param_deps,
|
||||
running=running,
|
||||
onerror=onerror,
|
||||
)
|
||||
|
||||
if rev == "workspace":
|
||||
@@ -142,14 +172,23 @@ def show(
|
||||
rev,
|
||||
sha_only=sha_only,
|
||||
param_deps=param_deps,
|
||||
running=running,
|
||||
onerror=onerror,
|
||||
)
|
||||
|
||||
# collect queued (not yet reproduced) experiments
|
||||
for stash_rev, entry in repo.experiments.stash_revs.items():
|
||||
if entry.baseline_rev in revs:
|
||||
experiment = _collect_experiment_commit(
|
||||
repo, stash_rev, stash=True, param_deps=param_deps
|
||||
)
|
||||
res[entry.baseline_rev][stash_rev] = experiment
|
||||
|
||||
# collect queued (not yet reproduced) experiments
|
||||
for stash_rev, entry in repo.experiments.stash_revs.items():
|
||||
if entry.baseline_rev in revs:
|
||||
if stash_rev not in running or not running[stash_rev].get(
|
||||
"last"
|
||||
):
|
||||
experiment = _collect_experiment_commit(
|
||||
repo,
|
||||
stash_rev,
|
||||
sha_only=sha_only,
|
||||
stash=stash_rev not in running,
|
||||
param_deps=param_deps,
|
||||
running=running,
|
||||
onerror=onerror,
|
||||
)
|
||||
res[entry.baseline_rev][stash_rev] = experiment
|
||||
return res
|
||||
|
||||
+17
-79
@@ -1,9 +1,8 @@
|
||||
import logging
|
||||
|
||||
from dvc.config import NoRemoteError
|
||||
from dvc.exceptions import DownloadError
|
||||
from dvc.exceptions import DownloadError, FileTransferError
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from ..scheme import Schemes
|
||||
from . import locked
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -16,7 +15,6 @@ def fetch(
|
||||
jobs=None,
|
||||
remote=None,
|
||||
all_branches=False,
|
||||
show_checksums=False,
|
||||
with_deps=False,
|
||||
all_tags=False,
|
||||
recursive=False,
|
||||
@@ -36,8 +34,6 @@ def fetch(
|
||||
config.NoRemoteError: thrown when downloading only local files and no
|
||||
remote is configured
|
||||
"""
|
||||
from dvc.objects.db.git import GitObjectDB
|
||||
|
||||
if isinstance(targets, str):
|
||||
targets = [targets]
|
||||
|
||||
@@ -63,36 +59,18 @@ def fetch(
|
||||
except DownloadError as exc:
|
||||
failed += exc.amount
|
||||
|
||||
external = set()
|
||||
for odb, objs in used.items():
|
||||
if odb is None:
|
||||
# objs contains naive objects to be pulled from specified remote
|
||||
d, f = _fetch_naive_objs(
|
||||
self,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
remote=remote,
|
||||
show_checksums=show_checksums,
|
||||
)
|
||||
downloaded += d
|
||||
failed += f
|
||||
elif isinstance(odb, GitObjectDB):
|
||||
# objs contains staged import objects which should be saved
|
||||
# last (after all other objects have been pulled)
|
||||
external.update(objs)
|
||||
else:
|
||||
d, f = fetch_from_odb(
|
||||
self,
|
||||
odb,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
)
|
||||
downloaded += d
|
||||
failed += f
|
||||
|
||||
if external:
|
||||
d, f = _fetch_external(self, external, jobs=jobs)
|
||||
for odb, obj_ids in sorted(
|
||||
used.items(),
|
||||
key=lambda item: item[0] is not None
|
||||
and item[0].fs.scheme == Schemes.MEMORY,
|
||||
):
|
||||
d, f = _fetch(
|
||||
self,
|
||||
obj_ids,
|
||||
jobs=jobs,
|
||||
remote=remote,
|
||||
odb=odb,
|
||||
)
|
||||
downloaded += d
|
||||
failed += f
|
||||
|
||||
@@ -102,51 +80,11 @@ def fetch(
|
||||
return downloaded
|
||||
|
||||
|
||||
def _fetch_naive_objs(repo, objs, **kwargs):
|
||||
# objs contains naive objects to be pulled from specified remote
|
||||
def _fetch(repo, obj_ids, **kwargs):
|
||||
downloaded = 0
|
||||
failed = 0
|
||||
try:
|
||||
downloaded += repo.cloud.pull(objs, **kwargs)
|
||||
except NoRemoteError:
|
||||
if any(obj.fs.scheme == Schemes.LOCAL for obj in objs):
|
||||
raise
|
||||
except DownloadError as exc:
|
||||
downloaded += repo.cloud.pull(obj_ids, **kwargs)
|
||||
except FileTransferError as exc:
|
||||
failed += exc.amount
|
||||
return downloaded, failed
|
||||
|
||||
|
||||
def fetch_from_odb(repo, odb, objs, **kwargs):
|
||||
from dvc.remote.base import Remote
|
||||
|
||||
downloaded = 0
|
||||
failed = 0
|
||||
remote = Remote.from_odb(odb)
|
||||
try:
|
||||
downloaded += remote.pull(
|
||||
repo.odb.local,
|
||||
objs,
|
||||
**kwargs,
|
||||
)
|
||||
except DownloadError as exc:
|
||||
failed += exc.amount
|
||||
return downloaded, failed
|
||||
|
||||
|
||||
def _fetch_external(repo, objs, **kwargs):
|
||||
from dvc.objects import save
|
||||
from dvc.objects.errors import ObjectError
|
||||
|
||||
results = []
|
||||
failed = 0
|
||||
|
||||
def callback(result):
|
||||
results.append(result)
|
||||
|
||||
for obj in objs:
|
||||
try:
|
||||
save(repo.odb.local, obj, download_callback=callback, **kwargs)
|
||||
except ObjectError:
|
||||
failed += 1
|
||||
|
||||
return sum(results), failed
|
||||
|
||||
+10
-18
@@ -1,14 +1,9 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Set
|
||||
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
|
||||
from ..scheme import Schemes
|
||||
from . import locked
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -49,19 +44,20 @@ def gc(
|
||||
|
||||
from contextlib import ExitStack
|
||||
|
||||
from dvc.objects.db import get_index
|
||||
from dvc.repo import Repo
|
||||
|
||||
if not repos:
|
||||
repos = []
|
||||
all_repos = [Repo(path) for path in repos]
|
||||
|
||||
used_objs: Set["HashFile"] = set()
|
||||
used_obj_ids = set()
|
||||
with ExitStack() as stack:
|
||||
for repo in all_repos:
|
||||
stack.enter_context(repo.lock)
|
||||
|
||||
for repo in all_repos + [self]:
|
||||
for objs in repo.used_objs(
|
||||
for obj_ids in repo.used_objs(
|
||||
all_branches=all_branches,
|
||||
with_deps=with_deps,
|
||||
all_tags=all_tags,
|
||||
@@ -71,26 +67,22 @@ def gc(
|
||||
force=force,
|
||||
jobs=jobs,
|
||||
).values():
|
||||
used_objs.update(objs)
|
||||
used_obj_ids.update(obj_ids)
|
||||
|
||||
for scheme, odb in self.odb.by_scheme():
|
||||
if not odb:
|
||||
continue
|
||||
|
||||
removed = odb.gc(
|
||||
{obj for obj in used_objs if obj.fs.scheme == scheme},
|
||||
jobs=jobs,
|
||||
)
|
||||
removed = odb.gc(used_obj_ids, jobs=jobs)
|
||||
if not removed:
|
||||
logger.info(f"No unused '{scheme}' cache to remove.")
|
||||
|
||||
if not cloud:
|
||||
return
|
||||
|
||||
remote = self.cloud.get_remote(remote, "gc -c")
|
||||
removed = remote.gc(
|
||||
{obj for obj in used_objs if obj.fs.scheme == Schemes.LOCAL},
|
||||
jobs=jobs,
|
||||
)
|
||||
if not removed:
|
||||
odb = self.cloud.get_remote_odb(remote, "gc -c")
|
||||
removed = odb.gc(used_obj_ids)
|
||||
if removed:
|
||||
get_index(odb).clear()
|
||||
else:
|
||||
logger.info("No unused cache to remove from remote.")
|
||||
|
||||
+6
-3
@@ -67,15 +67,18 @@ def imp_url(
|
||||
dvcfile.remove()
|
||||
|
||||
try:
|
||||
self.check_modified_graph([stage])
|
||||
new_index = self.index.add(stage)
|
||||
new_index.check_graph()
|
||||
except OutputDuplicationError as exc:
|
||||
raise OutputDuplicationError(exc.output, set(exc.stages) - {stage})
|
||||
|
||||
if no_exec:
|
||||
stage.ignore_outs()
|
||||
elif to_remote:
|
||||
remote = self.cloud.get_remote(remote, "import-url")
|
||||
stage.outs[0].transfer(url, odb=remote.odb, jobs=jobs)
|
||||
remote_odb = self.cloud.get_remote_odb(remote, "import-url")
|
||||
stage.outs[0].transfer(url, odb=remote_odb, jobs=jobs)
|
||||
stage.save_deps()
|
||||
stage.md5 = stage.compute_md5()
|
||||
else:
|
||||
stage.run(jobs=jobs)
|
||||
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
from contextlib import suppress
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
)
|
||||
|
||||
from funcy import cached_property, memoize, nullcontext
|
||||
|
||||
from dvc.utils import dict_md5
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from networkx import DiGraph
|
||||
from pygtrie import Trie
|
||||
|
||||
from dvc.dependency import Dependency, ParamsDependency
|
||||
from dvc.fs.base import BaseFileSystem
|
||||
from dvc.objects import HashInfo, ObjectDB
|
||||
from dvc.output import Output
|
||||
from dvc.repo.stage import StageLoad
|
||||
from dvc.stage import Stage
|
||||
from dvc.types import StrPath, TargetType
|
||||
|
||||
|
||||
ObjectContainer = Dict[Optional["ObjectDB"], Set["HashInfo"]]
|
||||
|
||||
|
||||
class Index:
|
||||
def __init__(
|
||||
self,
|
||||
repo: "Repo", # pylint: disable=redefined-outer-name
|
||||
fs: "BaseFileSystem" = None,
|
||||
stages: List["Stage"] = None,
|
||||
) -> None:
|
||||
"""Index is an immutable collection of stages.
|
||||
|
||||
Generally, Index is a complete collection of stages at a point in time.
|
||||
With "a point in time", it means it is collected from the user's
|
||||
workspace or a git revision.
|
||||
And, since Index is immutable, the collection is frozen in time.
|
||||
|
||||
Index provides multiple ways to view this collection:
|
||||
|
||||
stages - provides direct access to this collection
|
||||
outputs - provides direct access to the outputs
|
||||
objects - provides direct access to the objects
|
||||
graph -
|
||||
... and many more.
|
||||
|
||||
Index also provides ways to slice and dice this collection.
|
||||
Some `views` might not make sense when sliced (eg: pipelines/graph).
|
||||
"""
|
||||
|
||||
self.repo: "Repo" = repo
|
||||
self.fs: "BaseFileSystem" = fs or repo.fs
|
||||
self.stage_collector: "StageLoad" = repo.stage
|
||||
if stages is not None:
|
||||
self.stages: List["Stage"] = stages
|
||||
|
||||
@cached_property
|
||||
def stages(self) -> List["Stage"]: # pylint: disable=method-hidden
|
||||
# note that ideally we should be keeping this in a set as it is unique,
|
||||
# hashable and has no concept of orderliness on its own. But we depend
|
||||
# on this to be somewhat ordered for status/metrics/plots, etc.
|
||||
onerror = self.repo.stage_collection_error_handler
|
||||
return self.stage_collector.collect_repo(onerror=onerror)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
|
||||
rev = "workspace"
|
||||
if not isinstance(self.fs, LocalFileSystem):
|
||||
rev = self.repo.get_rev()[:7]
|
||||
return f"Index({self.repo}, fs@{rev})"
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.stages)
|
||||
|
||||
def __contains__(self, stage: "Stage") -> bool:
|
||||
# as we are keeping stages inside a list, it might be slower.
|
||||
return stage in self.stages
|
||||
|
||||
def __iter__(self) -> Iterator["Stage"]:
|
||||
yield from self.stages
|
||||
|
||||
def filter(self, filter_fn: Callable[["Stage"], bool]) -> "Index":
|
||||
stages_it = filter(filter_fn, self)
|
||||
return Index(self.repo, self.fs, stages=list(stages_it))
|
||||
|
||||
def slice(self, path: "StrPath") -> "Index":
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils.fs import path_isin
|
||||
|
||||
target_path = relpath(path, self.repo.root_dir)
|
||||
|
||||
def is_stage_inside_path(stage: "Stage") -> bool:
|
||||
return path_isin(stage.path_in_repo, target_path)
|
||||
|
||||
return self.filter(is_stage_inside_path)
|
||||
|
||||
@property
|
||||
def outs(self) -> Iterator["Output"]:
|
||||
for stage in self:
|
||||
yield from stage.outs
|
||||
|
||||
@property
|
||||
def decorated_outs(self) -> Iterator["Output"]:
|
||||
for output in self.outs:
|
||||
if output.is_decorated:
|
||||
yield output
|
||||
|
||||
@property
|
||||
def metrics(self) -> Iterator["Output"]:
|
||||
for output in self.outs:
|
||||
if output.is_metric:
|
||||
yield output
|
||||
|
||||
@property
|
||||
def plots(self) -> Iterator["Output"]:
|
||||
for output in self.outs:
|
||||
if output.is_plot:
|
||||
yield output
|
||||
|
||||
@property
|
||||
def deps(self) -> Iterator["Dependency"]:
|
||||
for stage in self:
|
||||
yield from stage.deps
|
||||
|
||||
@property
|
||||
def params(self) -> Iterator["ParamsDependency"]:
|
||||
from dvc.dependency import ParamsDependency
|
||||
|
||||
for dep in self.deps:
|
||||
if isinstance(dep, ParamsDependency):
|
||||
yield dep
|
||||
|
||||
@cached_property
|
||||
def outs_trie(self) -> "Trie":
|
||||
from dvc.repo.trie import build_outs_trie
|
||||
|
||||
return build_outs_trie(self.stages)
|
||||
|
||||
@property
|
||||
def graph(self) -> "DiGraph":
|
||||
return self.build_graph()
|
||||
|
||||
@cached_property
|
||||
def outs_graph(self) -> "DiGraph":
|
||||
from dvc.repo.graph import build_outs_graph
|
||||
|
||||
return build_outs_graph(self.graph, self.outs_trie)
|
||||
|
||||
def used_objs(
|
||||
self,
|
||||
targets: "TargetType" = None,
|
||||
with_deps: bool = False,
|
||||
remote: str = None,
|
||||
force: bool = False,
|
||||
recursive: bool = False,
|
||||
jobs: int = None,
|
||||
) -> "ObjectContainer":
|
||||
from collections import defaultdict
|
||||
from itertools import chain
|
||||
|
||||
from dvc.utils.collections import ensure_list
|
||||
|
||||
used: "ObjectContainer" = defaultdict(set)
|
||||
collect_targets: Sequence[Optional[str]] = (None,)
|
||||
if targets:
|
||||
collect_targets = ensure_list(targets)
|
||||
|
||||
pairs = chain.from_iterable(
|
||||
self.stage_collector.collect_granular(
|
||||
target, recursive=recursive, with_deps=with_deps
|
||||
)
|
||||
for target in collect_targets
|
||||
)
|
||||
|
||||
for stage, filter_info in pairs:
|
||||
for odb, objs in stage.get_used_objs(
|
||||
remote=remote,
|
||||
force=force,
|
||||
jobs=jobs,
|
||||
filter_info=filter_info,
|
||||
).items():
|
||||
used[odb].update(objs)
|
||||
return used
|
||||
|
||||
# Following methods help us treat the collection as a set-like structure
|
||||
# and provides faux-immutability.
|
||||
# These methods do not preserve stages order.
|
||||
|
||||
def update(self, stages: Iterable["Stage"]) -> "Index":
|
||||
new_stages = set(stages)
|
||||
# we remove existing stages with same hashes at first
|
||||
# and then re-add the new ones later.
|
||||
stages_set = (set(self.stages) - new_stages) | new_stages
|
||||
return Index(self.repo, self.fs, stages=list(stages_set))
|
||||
|
||||
def add(self, stage: "Stage") -> "Index":
|
||||
return self.update([stage])
|
||||
|
||||
def remove(
|
||||
self, stage: "Stage", ignore_not_existing: bool = False
|
||||
) -> "Index":
|
||||
stages = self._discard_stage(
|
||||
stage, ignore_not_existing=ignore_not_existing
|
||||
)
|
||||
return Index(self.repo, self.fs, stages=stages)
|
||||
|
||||
def discard(self, stage: "Stage") -> "Index":
|
||||
return self.remove(stage, ignore_not_existing=True)
|
||||
|
||||
def difference(self, stages: Iterable["Stage"]) -> "Index":
|
||||
stages_set = set(self.stages) - set(stages)
|
||||
return Index(self.repo, self.fs, stages=list(stages_set))
|
||||
|
||||
def _discard_stage(
|
||||
self, stage: "Stage", ignore_not_existing: bool = False
|
||||
) -> List["Stage"]:
|
||||
stages = self.stages[:]
|
||||
ctx = suppress(ValueError) if ignore_not_existing else nullcontext()
|
||||
with ctx:
|
||||
stages.remove(stage)
|
||||
return stages
|
||||
|
||||
@memoize
|
||||
def build_graph(self) -> "DiGraph":
|
||||
from dvc.repo.graph import build_graph
|
||||
|
||||
return build_graph(self.stages, self.outs_trie)
|
||||
|
||||
def check_graph(self) -> None:
|
||||
if not getattr(self.repo, "_skip_graph_checks", False):
|
||||
self.build_graph()
|
||||
|
||||
def dumpd(self) -> Dict[str, Dict]:
|
||||
def dump(stage: "Stage"):
|
||||
key = stage.path_in_repo
|
||||
try:
|
||||
key += ":" + stage.name # type: ignore[attr-defined]
|
||||
except AttributeError:
|
||||
pass
|
||||
return key, stage.dumpd()
|
||||
|
||||
return dict(dump(stage) for stage in self)
|
||||
|
||||
@cached_property
|
||||
def identifier(self) -> str:
|
||||
"""Unique identifier for the index.
|
||||
|
||||
We can use this to optimize and skip opening some indices
|
||||
eg: on push/pull/fetch/gc --all-commits.
|
||||
|
||||
Currently, it is unique to the platform (windows vs posix).
|
||||
"""
|
||||
return dict_md5(self.dumpd())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from funcy import log_durations
|
||||
|
||||
from dvc.repo import Repo
|
||||
|
||||
repo = Repo()
|
||||
index = Index(repo, repo.fs)
|
||||
print(index)
|
||||
with log_durations(print, "collecting stages"):
|
||||
# pylint: disable=pointless-statement
|
||||
print("no of stages", len(index.stages))
|
||||
with log_durations(print, "building graph"):
|
||||
index.build_graph()
|
||||
with log_durations(print, "calculating hash"):
|
||||
print(index.identifier)
|
||||
with log_durations(print, "updating"):
|
||||
index2 = index.update(index.stages)
|
||||
with log_durations(print, "calculating hash"):
|
||||
print(index2.identifier)
|
||||
+1
-10
@@ -1,10 +1,7 @@
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from dvc.exceptions import MetricDoesNotExistError, MetricsError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -44,15 +41,9 @@ class Live:
|
||||
if revs:
|
||||
revs = ["workspace", *revs]
|
||||
|
||||
if not os.path.exists(target):
|
||||
raise MetricDoesNotExistError([target])
|
||||
|
||||
metrics_path = target + ".json"
|
||||
|
||||
metrics = None
|
||||
with contextlib.suppress(MetricsError):
|
||||
metrics = self.repo.metrics.show(targets=[metrics_path])
|
||||
|
||||
metrics = self.repo.metrics.show(targets=[metrics_path])
|
||||
plots = self.repo.plots.show(target, recursive=True, revs=revs)
|
||||
|
||||
return metrics, plots
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
from dvc.exceptions import MetricsError
|
||||
from dvc.repo.experiments.utils import fix_exp_head
|
||||
from dvc.utils.diff import diff as _diff
|
||||
from dvc.utils.diff import format_dict
|
||||
|
||||
|
||||
def _get_metrics(repo, *args, revs=None, **kwargs):
|
||||
try:
|
||||
metrics = repo.metrics.show(*args, **kwargs, revs=revs)
|
||||
return metrics
|
||||
except MetricsError:
|
||||
return {}
|
||||
|
||||
|
||||
def diff(repo, *args, a_rev=None, b_rev=None, **kwargs):
|
||||
if repo.scm.no_commits:
|
||||
return {}
|
||||
@@ -22,9 +13,9 @@ def diff(repo, *args, a_rev=None, b_rev=None, **kwargs):
|
||||
a_rev = fix_exp_head(repo.scm, a_rev)
|
||||
b_rev = fix_exp_head(repo.scm, b_rev) or "workspace"
|
||||
|
||||
metrics = _get_metrics(repo, *args, **kwargs, revs=[a_rev, b_rev])
|
||||
old = metrics.get(a_rev, {})
|
||||
new = metrics.get(b_rev, {})
|
||||
metrics = repo.metrics.show(*args, **kwargs, revs=[a_rev, b_rev])
|
||||
old = metrics.get(a_rev, {}).get("data", {})
|
||||
new = metrics.get(b_rev, {}).get("data", {})
|
||||
|
||||
return _diff(
|
||||
format_dict(old), format_dict(new), with_unchanged=with_unchanged
|
||||
|
||||
+34
-40
@@ -1,19 +1,14 @@
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from dvc.exceptions import (
|
||||
MetricDoesNotExistError,
|
||||
NoMetricsFoundError,
|
||||
NoMetricsParsedError,
|
||||
)
|
||||
from dvc.fs.repo import RepoFileSystem
|
||||
from dvc.output import Output
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.collect import collect
|
||||
from dvc.repo.collect import DvcPaths, collect
|
||||
from dvc.repo.live import summary_path_info
|
||||
from dvc.scm.base import SCMError
|
||||
from dvc.utils.serialize import YAMLFileCorruptedError, load_yaml
|
||||
from dvc.utils import error_handler, errored_revisions, onerror_collect
|
||||
from dvc.utils.serialize import load_yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,7 +17,7 @@ def _is_metric(out: Output) -> bool:
|
||||
return bool(out.metric) or bool(out.live)
|
||||
|
||||
|
||||
def _to_path_infos(metrics: List[Output]) -> List[PathInfo]:
|
||||
def _to_path_infos(metrics: List[Output]) -> DvcPaths:
|
||||
result = []
|
||||
for out in metrics:
|
||||
if out.metric:
|
||||
@@ -70,7 +65,14 @@ def _extract_metrics(metrics, path, rev):
|
||||
return ret
|
||||
|
||||
|
||||
def _read_metrics(repo, metrics, rev):
|
||||
@error_handler
|
||||
def _read_metric(path, fs, rev, **kwargs):
|
||||
val = load_yaml(path, fs=fs)
|
||||
val = _extract_metrics(val, path, rev)
|
||||
return val or {}
|
||||
|
||||
|
||||
def _read_metrics(repo, metrics, rev, onerror=None):
|
||||
fs = RepoFileSystem(repo)
|
||||
|
||||
res = {}
|
||||
@@ -78,21 +80,16 @@ def _read_metrics(repo, metrics, rev):
|
||||
if not fs.isfile(metric):
|
||||
continue
|
||||
|
||||
try:
|
||||
val = load_yaml(metric, fs=fs)
|
||||
except (FileNotFoundError, YAMLFileCorruptedError):
|
||||
logger.debug(
|
||||
"failed to read '%s' on '%s'", metric, rev, exc_info=True
|
||||
)
|
||||
continue
|
||||
|
||||
val = _extract_metrics(val, metric, rev)
|
||||
if val not in (None, {}):
|
||||
res[str(metric)] = val
|
||||
res[str(metric)] = _read_metric(metric, fs, rev, onerror=onerror)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _gather_metrics(repo, targets, rev, recursive, onerror=None):
|
||||
metrics = _collect_metrics(repo, targets, rev, recursive)
|
||||
return _read_metrics(repo, metrics, rev, onerror=onerror)
|
||||
|
||||
|
||||
@locked
|
||||
def show(
|
||||
repo,
|
||||
@@ -102,33 +99,21 @@ def show(
|
||||
recursive=False,
|
||||
revs=None,
|
||||
all_commits=False,
|
||||
onerror=None,
|
||||
):
|
||||
res = {}
|
||||
metrics_found = False
|
||||
if onerror is None:
|
||||
onerror = onerror_collect
|
||||
|
||||
res = {}
|
||||
for rev in repo.brancher(
|
||||
revs=revs,
|
||||
all_branches=all_branches,
|
||||
all_tags=all_tags,
|
||||
all_commits=all_commits,
|
||||
):
|
||||
metrics = _collect_metrics(repo, targets, rev, recursive)
|
||||
|
||||
if not metrics_found and metrics:
|
||||
metrics_found = True
|
||||
|
||||
vals = _read_metrics(repo, metrics, rev)
|
||||
|
||||
if vals:
|
||||
res[rev] = vals
|
||||
|
||||
if not res:
|
||||
if metrics_found:
|
||||
raise NoMetricsParsedError("metrics")
|
||||
elif targets:
|
||||
raise MetricDoesNotExistError(targets)
|
||||
else:
|
||||
raise NoMetricsFoundError("metrics", "-m/-M")
|
||||
res[rev] = error_handler(_gather_metrics)(
|
||||
repo, targets, rev, recursive, onerror=onerror
|
||||
)
|
||||
|
||||
# Hide workspace metrics if they are the same as in the active branch
|
||||
try:
|
||||
@@ -141,4 +126,13 @@ def show(
|
||||
if res.get("workspace") == res.get(active_branch):
|
||||
res.pop("workspace", None)
|
||||
|
||||
errored = errored_revisions(res)
|
||||
if errored:
|
||||
from dvc.ui import ui
|
||||
|
||||
ui.error_write(
|
||||
"DVC failed to load some metrics for following revisions:"
|
||||
f" '{', '.join(errored)}'."
|
||||
)
|
||||
|
||||
return res
|
||||
|
||||
+4
-13
@@ -2,16 +2,6 @@ from dvc.repo.experiments.utils import fix_exp_head
|
||||
from dvc.utils.diff import diff as _diff
|
||||
from dvc.utils.diff import format_dict
|
||||
|
||||
from .show import NoParamsError
|
||||
|
||||
|
||||
def _get_params(repo, *args, revs=None, **kwargs):
|
||||
try:
|
||||
params = repo.params.show(*args, **kwargs, revs=revs)
|
||||
return params
|
||||
except NoParamsError:
|
||||
return {}
|
||||
|
||||
|
||||
def diff(repo, *args, a_rev=None, b_rev=None, **kwargs):
|
||||
if repo.scm.no_commits:
|
||||
@@ -23,9 +13,10 @@ def diff(repo, *args, a_rev=None, b_rev=None, **kwargs):
|
||||
a_rev = fix_exp_head(repo.scm, a_rev)
|
||||
b_rev = fix_exp_head(repo.scm, b_rev) or "workspace"
|
||||
|
||||
params = _get_params(repo, *args, **kwargs, revs=[a_rev, b_rev])
|
||||
old = params.get(a_rev, {})
|
||||
new = params.get(b_rev, {})
|
||||
params = repo.params.show(*args, **kwargs, revs=[a_rev, b_rev])
|
||||
|
||||
old = params.get(a_rev, {}).get("data", {})
|
||||
new = params.get(b_rev, {}).get("data", {})
|
||||
|
||||
return _diff(
|
||||
format_dict(old), format_dict(new), with_unchanged=with_unchanged
|
||||
|
||||
+59
-37
@@ -1,16 +1,17 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from copy import copy
|
||||
from typing import TYPE_CHECKING, List, Tuple
|
||||
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from dvc.dependency.param import ParamsDependency
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.collect import collect
|
||||
from dvc.scm.base import SCMError
|
||||
from dvc.stage import PipelineStage
|
||||
from dvc.utils.serialize import LOADERS, ParseError
|
||||
from dvc.ui import ui
|
||||
from dvc.utils import error_handler, errored_revisions, onerror_collect
|
||||
from dvc.utils.serialize import LOADERS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.output import Output
|
||||
@@ -20,10 +21,6 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NoParamsError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
def _is_params(dep: "Output"):
|
||||
return isinstance(dep, ParamsDependency)
|
||||
|
||||
@@ -31,6 +28,7 @@ def _is_params(dep: "Output"):
|
||||
def _collect_configs(
|
||||
repo: "Repo", rev, targets=None
|
||||
) -> Tuple[List["Output"], List["DvcPath"]]:
|
||||
|
||||
params, path_infos = collect(
|
||||
repo,
|
||||
targets=targets or [],
|
||||
@@ -43,47 +41,49 @@ def _collect_configs(
|
||||
default_params = (
|
||||
PathInfo(repo.root_dir) / ParamsDependency.DEFAULT_PARAMS_FILE
|
||||
)
|
||||
if default_params not in all_path_infos:
|
||||
if default_params not in all_path_infos and repo.fs.exists(
|
||||
default_params
|
||||
):
|
||||
path_infos.append(default_params)
|
||||
return params, path_infos
|
||||
|
||||
|
||||
def _read_path_info(fs, path_info, rev):
|
||||
if not fs.exists(path_info):
|
||||
return None
|
||||
|
||||
@error_handler
|
||||
def _read_path_info(fs, path_info, **kwargs):
|
||||
suffix = path_info.suffix.lower()
|
||||
loader = LOADERS[suffix]
|
||||
try:
|
||||
return loader(path_info, fs=fs)
|
||||
except ParseError:
|
||||
logger.debug(
|
||||
"failed to read '%s' on '%s'", path_info, rev, exc_info=True
|
||||
)
|
||||
return None
|
||||
return loader(path_info, fs=fs)
|
||||
|
||||
|
||||
def _read_params(repo, params, params_path_infos, rev, deps=False):
|
||||
res = defaultdict(dict)
|
||||
def _read_params(
|
||||
repo,
|
||||
params,
|
||||
params_path_infos,
|
||||
deps=False,
|
||||
onerror: Optional[Callable] = None,
|
||||
):
|
||||
res: Dict[str, Dict] = defaultdict(dict)
|
||||
path_infos = copy(params_path_infos)
|
||||
|
||||
if deps:
|
||||
for param in params:
|
||||
res[str(param.path_info)].update(param.read_params_d())
|
||||
params_dict = error_handler(param.read_params_d)(onerror=onerror)
|
||||
if params_dict:
|
||||
res[str(param.path_info)] = params_dict
|
||||
else:
|
||||
path_infos += [param.path_info for param in params]
|
||||
|
||||
for path_info in path_infos:
|
||||
from_path = _read_path_info(repo.fs, path_info, rev)
|
||||
from_path = _read_path_info(repo.fs, path_info, onerror=onerror)
|
||||
if from_path:
|
||||
res[str(path_info)] = from_path
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _collect_vars(repo, params):
|
||||
vars_params = defaultdict(dict)
|
||||
for stage in repo.stages:
|
||||
def _collect_vars(repo, params) -> Dict:
|
||||
vars_params: Dict[str, Dict] = defaultdict(dict)
|
||||
for stage in repo.index.stages:
|
||||
if isinstance(stage, PipelineStage) and stage.tracked_vars:
|
||||
for file, vars_ in stage.tracked_vars.items():
|
||||
# `params` file are shown regardless of `tracked` or not
|
||||
@@ -96,24 +96,19 @@ def _collect_vars(repo, params):
|
||||
|
||||
|
||||
@locked
|
||||
def show(repo, revs=None, targets=None, deps=False):
|
||||
def show(repo, revs=None, targets=None, deps=False, onerror: Callable = None):
|
||||
if onerror is None:
|
||||
onerror = onerror_collect
|
||||
res = {}
|
||||
|
||||
for branch in repo.brancher(revs=revs):
|
||||
params, params_path_infos = _collect_configs(repo, branch, targets)
|
||||
params = _read_params(repo, params, params_path_infos, branch, deps)
|
||||
vars_params = _collect_vars(repo, params)
|
||||
|
||||
# NOTE: only those that are not added as a ParamDependency are included
|
||||
# so we don't need to recursively merge them yet.
|
||||
params.update(vars_params)
|
||||
params = error_handler(_gather_params)(
|
||||
repo=repo, rev=branch, targets=targets, deps=deps, onerror=onerror
|
||||
)
|
||||
|
||||
if params:
|
||||
res[branch] = params
|
||||
|
||||
if not res:
|
||||
raise NoParamsError("no parameter configs files in this repository")
|
||||
|
||||
# Hide workspace params if they are the same as in the active branch
|
||||
try:
|
||||
active_branch = repo.scm.active_branch()
|
||||
@@ -125,4 +120,31 @@ def show(repo, revs=None, targets=None, deps=False):
|
||||
if res.get("workspace") == res.get(active_branch):
|
||||
res.pop("workspace", None)
|
||||
|
||||
errored = errored_revisions(res)
|
||||
if errored:
|
||||
ui.error_write(
|
||||
"DVC failed to load some parameters for following revisions:"
|
||||
f" '{', '.join(errored)}'."
|
||||
)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _gather_params(repo, rev, targets=None, deps=False, onerror=None):
|
||||
param_outs, params_path_infos = _collect_configs(
|
||||
repo, rev, targets=targets
|
||||
)
|
||||
params = _read_params(
|
||||
repo,
|
||||
params=param_outs,
|
||||
params_path_infos=params_path_infos,
|
||||
deps=deps,
|
||||
onerror=onerror,
|
||||
)
|
||||
vars_params = _collect_vars(repo, params)
|
||||
|
||||
# NOTE: only those that are not added as a ParamDependency are
|
||||
# included so we don't need to recursively merge them yet.
|
||||
for key, vals in vars_params.items():
|
||||
params[key]["data"] = vals
|
||||
return params
|
||||
|
||||
+134
-109
@@ -1,17 +1,22 @@
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
from collections import OrderedDict
|
||||
from typing import TYPE_CHECKING, Callable, Dict, List, Optional
|
||||
|
||||
from funcy import cached_property, first, project
|
||||
|
||||
from dvc.exceptions import (
|
||||
DvcException,
|
||||
MetricDoesNotExistError,
|
||||
NoMetricsFoundError,
|
||||
NoMetricsParsedError,
|
||||
)
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.repo.plots.data import PlotMetricTypeError
|
||||
from dvc.types import StrPath
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils import (
|
||||
error_handler,
|
||||
errored_revisions,
|
||||
onerror_collect,
|
||||
relpath,
|
||||
)
|
||||
from dvc.utils.serialize import LOADERS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.output import Output
|
||||
@@ -41,18 +46,17 @@ class Plots:
|
||||
targets: List[str] = None,
|
||||
revs: List[str] = None,
|
||||
recursive: bool = False,
|
||||
onerror: Optional[Callable] = None,
|
||||
props: Optional[Dict] = None,
|
||||
) -> Dict[str, Dict]:
|
||||
"""Collects all props and data for plots.
|
||||
|
||||
Returns a structure like:
|
||||
{rev: {plots.csv: {
|
||||
props: {x: ..., "header": ..., ...},
|
||||
data: "...data as a string...",
|
||||
data: "unstructured data (as stored for given extension)",
|
||||
}}}
|
||||
Data parsing is postponed, since it's affected by props.
|
||||
"""
|
||||
from dvc.config import NoRemoteError
|
||||
from dvc.fs.repo import RepoFileSystem
|
||||
from dvc.utils.collections import ensure_list
|
||||
|
||||
targets = ensure_list(targets)
|
||||
@@ -62,70 +66,68 @@ class Plots:
|
||||
if revs is not None and rev not in revs:
|
||||
continue
|
||||
rev = rev or "workspace"
|
||||
data[rev] = self._collect_from_revision(
|
||||
revision=rev,
|
||||
targets=targets,
|
||||
recursive=recursive,
|
||||
onerror=onerror,
|
||||
props=props,
|
||||
)
|
||||
|
||||
fs = RepoFileSystem(self.repo)
|
||||
plots = _collect_plots(self.repo, targets, rev, recursive)
|
||||
for path_info, props in plots.items():
|
||||
errored = errored_revisions(data)
|
||||
if errored:
|
||||
from dvc.ui import ui
|
||||
|
||||
if rev not in data:
|
||||
data[rev] = {}
|
||||
|
||||
if fs.isdir(path_info):
|
||||
plot_files = []
|
||||
try:
|
||||
for pi in fs.walk_files(path_info):
|
||||
plot_files.append(
|
||||
(pi, relpath(pi, self.repo.root_dir))
|
||||
)
|
||||
except NoRemoteError:
|
||||
logger.debug(
|
||||
(
|
||||
"Could not find cache for directory '%s' on "
|
||||
"'%s'. Files inside will not be plotted."
|
||||
),
|
||||
path_info,
|
||||
rev,
|
||||
)
|
||||
continue
|
||||
else:
|
||||
plot_files = [
|
||||
(path_info, relpath(path_info, self.repo.root_dir))
|
||||
]
|
||||
|
||||
for path, repo_path in plot_files:
|
||||
data[rev].update({repo_path: {"props": props}})
|
||||
|
||||
# Load data from git or dvc cache
|
||||
try:
|
||||
with fs.open(path) as fd:
|
||||
data[rev][repo_path]["data"] = fd.read()
|
||||
except FileNotFoundError:
|
||||
# This might happen simply because cache is absent
|
||||
logger.debug(
|
||||
(
|
||||
"Could not find '%s' on '%s'. "
|
||||
"File will not be plotted"
|
||||
),
|
||||
path,
|
||||
rev,
|
||||
)
|
||||
except UnicodeDecodeError:
|
||||
logger.debug(
|
||||
(
|
||||
"'%s' at '%s' is binary file. It will not be "
|
||||
"plotted."
|
||||
),
|
||||
path,
|
||||
rev,
|
||||
)
|
||||
ui.error_write(
|
||||
"DVC failed to load some plots for following revisions: "
|
||||
f"'{', '.join(errored)}'."
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@error_handler
|
||||
def _collect_from_revision(
|
||||
self,
|
||||
targets: Optional[List[str]] = None,
|
||||
revision: Optional[str] = None,
|
||||
recursive: bool = False,
|
||||
onerror: Optional[Callable] = None,
|
||||
props: Optional[Dict] = None,
|
||||
):
|
||||
from dvc.fs.repo import RepoFileSystem
|
||||
|
||||
fs = RepoFileSystem(self.repo)
|
||||
plots = _collect_plots(self.repo, targets, revision, recursive)
|
||||
res = {}
|
||||
for path_info, rev_props in plots.items():
|
||||
|
||||
if fs.isdir(path_info):
|
||||
plot_files = []
|
||||
for pi in fs.walk_files(path_info):
|
||||
plot_files.append((pi, relpath(pi, self.repo.root_dir)))
|
||||
else:
|
||||
plot_files = [
|
||||
(path_info, relpath(path_info, self.repo.root_dir))
|
||||
]
|
||||
|
||||
props = props or {}
|
||||
|
||||
for path, repo_path in plot_files:
|
||||
res[repo_path] = {"props": rev_props}
|
||||
res[repo_path].update(
|
||||
parse(
|
||||
fs,
|
||||
path,
|
||||
rev_props=rev_props,
|
||||
props=props,
|
||||
onerror=onerror,
|
||||
)
|
||||
)
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def render(data, revs=None, props=None, templates=None):
|
||||
"""Renders plots"""
|
||||
from dvc.repo.plots.data import PlotParsingError
|
||||
|
||||
props = props or {}
|
||||
|
||||
# Merge data by plot file and apply overriding props
|
||||
@@ -133,20 +135,14 @@ class Plots:
|
||||
|
||||
result = {}
|
||||
for datafile, desc in plots.items():
|
||||
try:
|
||||
result[datafile] = _render(
|
||||
datafile, desc["data"], desc["props"], templates
|
||||
)
|
||||
except PlotParsingError as e:
|
||||
logger.debug(
|
||||
"failed to read '%s' on '%s'",
|
||||
e.path,
|
||||
e.revision,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not any(result.values()):
|
||||
raise NoMetricsParsedError("plots")
|
||||
rendered = _render(
|
||||
datafile,
|
||||
desc["data"],
|
||||
desc["props"],
|
||||
templates,
|
||||
)
|
||||
if rendered:
|
||||
result[datafile] = rendered
|
||||
|
||||
return result
|
||||
|
||||
@@ -157,25 +153,13 @@ class Plots:
|
||||
props=None,
|
||||
templates=None,
|
||||
recursive=False,
|
||||
onerror=None,
|
||||
):
|
||||
from dvc.utils.collections import ensure_list
|
||||
|
||||
data = self.collect(targets, revs, recursive)
|
||||
|
||||
# If any mentioned plot doesn't have any data then that's an error
|
||||
for target in ensure_list(targets):
|
||||
rpath = relpath(target, self.repo.root_dir)
|
||||
if not any(
|
||||
"data" in rev_data[key]
|
||||
for rev_data in data.values()
|
||||
for key, d in rev_data.items()
|
||||
if rpath in key
|
||||
):
|
||||
raise MetricDoesNotExistError([target])
|
||||
|
||||
# No data at all is a special error with a special message
|
||||
if not data:
|
||||
raise NoMetricsFoundError("plots", "--plots/--plots-no-cache")
|
||||
if onerror is None:
|
||||
onerror = onerror_collect
|
||||
data = self.collect(
|
||||
targets, revs, recursive, onerror=onerror, props=props
|
||||
)
|
||||
|
||||
if templates is None:
|
||||
templates = self.templates
|
||||
@@ -273,11 +257,27 @@ def _collect_plots(
|
||||
rev=rev,
|
||||
recursive=recursive,
|
||||
)
|
||||
|
||||
result = {plot.path_info: _plot_props(plot) for plot in plots}
|
||||
result.update({path_info: {} for path_info in path_infos})
|
||||
return result
|
||||
|
||||
|
||||
@error_handler
|
||||
def parse(fs, path, props=None, rev_props=None, **kwargs):
|
||||
props = props or {}
|
||||
rev_props = rev_props or {}
|
||||
_, extension = os.path.splitext(path)
|
||||
if extension in (".tsv", ".csv"):
|
||||
header = {**rev_props, **props}.get("header", True)
|
||||
if extension == ".csv":
|
||||
return _load_sv(path=path, fs=fs, delimiter=",", header=header)
|
||||
return _load_sv(path=path, fs=fs, delimiter="\t", header=header)
|
||||
if extension in LOADERS or extension in (".yml", ".yaml"):
|
||||
return LOADERS[extension](path=path, fs=fs)
|
||||
raise PlotMetricTypeError(path)
|
||||
|
||||
|
||||
def _plot_props(out: "Output") -> Dict:
|
||||
from dvc.schema import PLOT_PROPS
|
||||
|
||||
@@ -305,7 +305,7 @@ def _prepare_plots(data, revs, props):
|
||||
if rev not in data:
|
||||
continue
|
||||
|
||||
for datafile, desc in data[rev].items():
|
||||
for datafile, desc in data[rev].get("data", {}).items():
|
||||
# We silently skip on an absent data file,
|
||||
# see also try/except/pass in .collect()
|
||||
if "data" not in desc:
|
||||
@@ -355,19 +355,44 @@ def _render(datafile, datas, props, templates):
|
||||
|
||||
# Parse all data, preprocess it and collect as a list of dicts
|
||||
data = []
|
||||
for rev, datablob in datas.items():
|
||||
rev_data = plot_data(datafile, rev, datablob).to_datapoints(
|
||||
for rev, unstructured_data in datas.items():
|
||||
rev_data = plot_data(datafile, rev, unstructured_data).to_datapoints(
|
||||
fields=fields,
|
||||
path=props.get("path"),
|
||||
header=props.get("header", True),
|
||||
append_index=props.get("append_index", False),
|
||||
)
|
||||
data.extend(rev_data)
|
||||
|
||||
# If y is not set then use last field not used yet
|
||||
if not props.get("y") and template.has_anchor("y"):
|
||||
fields = list(first(data))
|
||||
skip = (PlotData.REVISION_FIELD, props.get("x"))
|
||||
props["y"] = first(f for f in reversed(fields) if f not in skip)
|
||||
if data:
|
||||
if not props.get("y") and template.has_anchor("y"):
|
||||
fields = list(first(data))
|
||||
skip = (PlotData.REVISION_FIELD, props.get("x"))
|
||||
props["y"] = first(f for f in reversed(fields) if f not in skip)
|
||||
|
||||
return template.render(data, props=props)
|
||||
return template.render(data, props=props)
|
||||
return None
|
||||
|
||||
|
||||
def _load_sv(path, fs, delimiter=",", header=True):
|
||||
with fs.open(path, "r") as fd:
|
||||
content = fd.read()
|
||||
|
||||
first_row = first(csv.reader(io.StringIO(content)))
|
||||
|
||||
if header:
|
||||
reader = csv.DictReader(io.StringIO(content), delimiter=delimiter)
|
||||
else:
|
||||
reader = csv.DictReader(
|
||||
io.StringIO(content),
|
||||
delimiter=delimiter,
|
||||
fieldnames=[str(i) for i in range(len(first_row))],
|
||||
)
|
||||
|
||||
fieldnames = reader.fieldnames
|
||||
data = list(reader)
|
||||
|
||||
return [
|
||||
OrderedDict([(field, data_point[field]) for field in fieldnames])
|
||||
for data_point in data
|
||||
]
|
||||
|
||||
+15
-93
@@ -1,13 +1,10 @@
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from copy import copy
|
||||
|
||||
from funcy import first, reraise
|
||||
from funcy import first
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.utils.serialize import ParseError, parse_json, parse_yaml
|
||||
from dvc.utils.serialize import ParseError
|
||||
|
||||
|
||||
class PlotMetricTypeError(DvcException):
|
||||
@@ -31,19 +28,15 @@ class PlotParsingError(ParseError):
|
||||
self.path = path
|
||||
self.revision = revision
|
||||
|
||||
super().__init__(path, f"revision: {revision}")
|
||||
super().__init__(path, f"revision: '{revision}'")
|
||||
|
||||
|
||||
def plot_data(filename, revision, content):
|
||||
_, extension = os.path.splitext(filename.lower())
|
||||
if extension == ".json":
|
||||
return JSONPlotData(filename, revision, content)
|
||||
if extension == ".csv":
|
||||
return CSVPlotData(filename, revision, content)
|
||||
if extension == ".tsv":
|
||||
return CSVPlotData(filename, revision, content, delimiter="\t")
|
||||
if extension == ".yaml":
|
||||
return YAMLPlotData(filename, revision, content)
|
||||
if extension in (".json", ".yaml"):
|
||||
return DictData(filename, revision, content)
|
||||
if extension in (".csv", ".tsv"):
|
||||
return ListData(filename, revision, content)
|
||||
raise PlotMetricTypeError(filename)
|
||||
|
||||
|
||||
@@ -71,34 +64,6 @@ def _filter_fields(data_points, filename, revision, fields=None, **kwargs):
|
||||
return new_data
|
||||
|
||||
|
||||
def _apply_path(data, path=None, **kwargs):
|
||||
if not path or not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
import jsonpath_ng
|
||||
|
||||
found = jsonpath_ng.parse(path).find(data)
|
||||
first_datum = first(found)
|
||||
if (
|
||||
len(found) == 1
|
||||
and isinstance(first_datum.value, list)
|
||||
and isinstance(first(first_datum.value), dict)
|
||||
):
|
||||
data_points = first_datum.value
|
||||
elif len(first_datum.path.fields) == 1:
|
||||
field_name = first(first_datum.path.fields)
|
||||
data_points = [{field_name: datum.value} for datum in found]
|
||||
else:
|
||||
raise PlotDataStructureError()
|
||||
|
||||
if not isinstance(data_points, list) or not (
|
||||
isinstance(first(data_points), dict)
|
||||
):
|
||||
raise PlotDataStructureError()
|
||||
|
||||
return data_points
|
||||
|
||||
|
||||
def _lists(dictionary):
|
||||
for _, value in dictionary.items():
|
||||
if isinstance(value, dict):
|
||||
@@ -148,18 +113,11 @@ class PlotData:
|
||||
self.revision = revision
|
||||
self.content = content
|
||||
|
||||
def raw(self, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def _processors(self):
|
||||
return [_filter_fields, _append_index, _append_revision]
|
||||
|
||||
def to_datapoints(self, **kwargs):
|
||||
with reraise(
|
||||
[ParseError, csv.Error],
|
||||
PlotParsingError(self.filename, self.revision),
|
||||
):
|
||||
data = self.raw(**kwargs)
|
||||
data = self.content
|
||||
|
||||
for data_proc in self._processors():
|
||||
data = data_proc(
|
||||
@@ -168,49 +126,13 @@ class PlotData:
|
||||
return data
|
||||
|
||||
|
||||
class JSONPlotData(PlotData):
|
||||
def raw(self, **kwargs):
|
||||
return parse_json(
|
||||
self.content, self.filename, object_pairs_hook=OrderedDict
|
||||
)
|
||||
|
||||
def _processors(self):
|
||||
parent_processors = super()._processors()
|
||||
return [_apply_path, _find_data] + parent_processors
|
||||
|
||||
|
||||
class CSVPlotData(PlotData):
|
||||
def __init__(self, filename, revision, content, delimiter=","):
|
||||
super().__init__(filename, revision, content)
|
||||
self.delimiter = delimiter
|
||||
|
||||
def raw(self, header=True, **kwargs): # pylint: disable=arguments-differ
|
||||
first_row = first(csv.reader(io.StringIO(self.content)))
|
||||
|
||||
if header:
|
||||
reader = csv.DictReader(
|
||||
io.StringIO(self.content), delimiter=self.delimiter
|
||||
)
|
||||
else:
|
||||
reader = csv.DictReader(
|
||||
io.StringIO(self.content),
|
||||
delimiter=self.delimiter,
|
||||
fieldnames=[str(i) for i in range(len(first_row))],
|
||||
)
|
||||
|
||||
fieldnames = reader.fieldnames
|
||||
data = list(reader)
|
||||
|
||||
return [
|
||||
OrderedDict([(field, data_point[field]) for field in fieldnames])
|
||||
for data_point in data
|
||||
]
|
||||
|
||||
|
||||
class YAMLPlotData(PlotData):
|
||||
def raw(self, **kwargs):
|
||||
return parse_yaml(self.content, self.filename, typ="rt")
|
||||
|
||||
class DictData(PlotData):
|
||||
# For files usually parsed as dicts: eg JSON, Yaml
|
||||
def _processors(self):
|
||||
parent_processors = super()._processors()
|
||||
return [_find_data] + parent_processors
|
||||
|
||||
|
||||
class ListData(PlotData):
|
||||
# For files parsed as list: CSV, TSV
|
||||
pass
|
||||
|
||||
@@ -84,7 +84,7 @@ class Template:
|
||||
|
||||
@classmethod
|
||||
def _anchor_str(cls, name):
|
||||
return '"{}"'.format(cls.anchor(name))
|
||||
return f'"{cls.anchor(name)}"'
|
||||
|
||||
@classmethod
|
||||
def _fill_metadata(cls, content, props):
|
||||
|
||||
+9
-3
@@ -1,3 +1,5 @@
|
||||
from dvc.exceptions import FileTransferError, UploadError
|
||||
|
||||
from ..utils import glob_targets
|
||||
from . import locked
|
||||
|
||||
@@ -39,7 +41,11 @@ def push(
|
||||
)
|
||||
|
||||
pushed = len(used_run_cache)
|
||||
for odb, objs in used.items():
|
||||
if odb is None:
|
||||
pushed += self.cloud.push(objs, jobs, remote=remote)
|
||||
for odb, obj_ids in used.items():
|
||||
if odb and odb.read_only:
|
||||
continue
|
||||
try:
|
||||
pushed += self.cloud.push(obj_ids, jobs, remote=remote, odb=odb)
|
||||
except FileTransferError as exc:
|
||||
raise UploadError(exc.amount)
|
||||
return pushed
|
||||
|
||||
@@ -108,7 +108,7 @@ def reproduce(
|
||||
|
||||
stages = set()
|
||||
if pipeline or all_pipelines:
|
||||
pipelines = get_pipelines(self.graph)
|
||||
pipelines = get_pipelines(self.index.graph)
|
||||
if all_pipelines:
|
||||
used_pipelines = pipelines
|
||||
else:
|
||||
@@ -132,7 +132,7 @@ def reproduce(
|
||||
)
|
||||
)
|
||||
|
||||
return _reproduce_stages(self.graph, list(stages), **kwargs)
|
||||
return _reproduce_stages(self.index.graph, list(stages), **kwargs)
|
||||
|
||||
|
||||
def _reproduce_stages(
|
||||
|
||||
+20
-11
@@ -110,8 +110,9 @@ def locked(f):
|
||||
|
||||
|
||||
class StageLoad:
|
||||
def __init__(self, repo: "Repo") -> None:
|
||||
def __init__(self, repo: "Repo", fs=None) -> None:
|
||||
self.repo: "Repo" = repo
|
||||
self._fs = fs
|
||||
|
||||
@locked
|
||||
def add(
|
||||
@@ -164,7 +165,6 @@ class StageLoad:
|
||||
is_valid_name,
|
||||
prepare_file_path,
|
||||
validate_kwargs,
|
||||
validate_state,
|
||||
)
|
||||
|
||||
stage_data = validate_kwargs(
|
||||
@@ -184,7 +184,13 @@ class StageLoad:
|
||||
stage_cls, repo=self.repo, path=path, **stage_data
|
||||
)
|
||||
if validate:
|
||||
validate_state(self.repo, stage, force=force)
|
||||
if not force:
|
||||
from dvc.stage.utils import check_stage_exists
|
||||
|
||||
check_stage_exists(self.repo, stage, stage.path)
|
||||
|
||||
new_index = self.repo.index.add(stage)
|
||||
new_index.check_graph()
|
||||
|
||||
restore_meta(stage)
|
||||
return stage
|
||||
@@ -305,11 +311,13 @@ class StageLoad:
|
||||
|
||||
@property
|
||||
def fs(self):
|
||||
if self._fs:
|
||||
return self._fs
|
||||
return self.repo.fs
|
||||
|
||||
@property
|
||||
def graph(self) -> "DiGraph":
|
||||
return self.repo.graph
|
||||
return self.repo.index.graph
|
||||
|
||||
def collect(
|
||||
self,
|
||||
@@ -351,9 +359,9 @@ class StageLoad:
|
||||
glob: Use `target` as a pattern to match stages in a file.
|
||||
"""
|
||||
if not target:
|
||||
return list(graph) if graph else self.repo.stages
|
||||
return list(graph) if graph else list(self.repo.index)
|
||||
|
||||
if recursive and self.repo.fs.isdir(target):
|
||||
if recursive and self.fs.isdir(target):
|
||||
from dvc.repo.graph import collect_inside_path
|
||||
|
||||
path = os.path.abspath(target)
|
||||
@@ -392,7 +400,7 @@ class StageLoad:
|
||||
(see `collect()` for other arguments)
|
||||
"""
|
||||
if not target:
|
||||
return [StageInfo(stage) for stage in self.repo.stages]
|
||||
return [StageInfo(stage) for stage in self.repo.index]
|
||||
|
||||
stages, file, _ = _collect_specific_target(
|
||||
self, target, with_deps, recursive, accept_group
|
||||
@@ -432,7 +440,7 @@ class StageLoad:
|
||||
|
||||
return [StageInfo(stage) for stage in stages]
|
||||
|
||||
def collect_repo(self, onerror: Callable[[str, Exception], None] = None):
|
||||
def _collect_repo(self, onerror: Callable[[str, Exception], None] = None):
|
||||
"""Collects all of the stages present in the DVC repo.
|
||||
|
||||
Args:
|
||||
@@ -465,7 +473,6 @@ class StageLoad:
|
||||
# trailing slash needed to check if a directory is gitignored
|
||||
return dir_path in outs or is_ignored(f"{dir_path}{sep}")
|
||||
|
||||
stages = []
|
||||
for root, dirs, files in self.repo.dvcignore.walk(
|
||||
self.fs, self.repo.root_dir
|
||||
):
|
||||
@@ -480,7 +487,7 @@ class StageLoad:
|
||||
continue
|
||||
raise
|
||||
|
||||
stages.extend(new_stages)
|
||||
yield from new_stages
|
||||
outs.update(
|
||||
out.fspath
|
||||
for stage in new_stages
|
||||
@@ -488,4 +495,6 @@ class StageLoad:
|
||||
if out.scheme == "local"
|
||||
)
|
||||
dirs[:] = [d for d in dirs if not is_out_or_ignored(root, d)]
|
||||
return stages
|
||||
|
||||
def collect_repo(self, onerror: Callable[[str, Exception], None] = None):
|
||||
return list(self._collect_repo(onerror))
|
||||
|
||||
+5
-17
@@ -76,8 +76,6 @@ def _cloud_status(
|
||||
|
||||
{ "bar": "deleted" }
|
||||
"""
|
||||
import dvc.remote.base as cloud
|
||||
|
||||
used = self.used_objs(
|
||||
targets,
|
||||
all_branches=all_branches,
|
||||
@@ -91,26 +89,16 @@ def _cloud_status(
|
||||
)
|
||||
|
||||
ret = {}
|
||||
for odb, objs in used.items():
|
||||
for odb, obj_ids in used.items():
|
||||
if odb is not None:
|
||||
# ignore imported objects
|
||||
continue
|
||||
status_info = self.cloud.status(
|
||||
objs, jobs, remote=remote, log_missing=False
|
||||
obj_ids, jobs, remote=remote, log_missing=False
|
||||
)
|
||||
for info in status_info.values():
|
||||
name = info["name"]
|
||||
status_ = info["status"]
|
||||
if status_ == cloud.STATUS_OK:
|
||||
continue
|
||||
|
||||
prefix_map = {
|
||||
cloud.STATUS_DELETED: "deleted",
|
||||
cloud.STATUS_NEW: "new",
|
||||
cloud.STATUS_MISSING: "missing",
|
||||
}
|
||||
|
||||
ret[name] = prefix_map[status_]
|
||||
for status_ in ("deleted", "new", "missing"):
|
||||
for hash_info in getattr(status_info, status_, []):
|
||||
ret[hash_info.obj_name] = status_
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
+1
-3
@@ -28,9 +28,7 @@ class RWLockFileCorruptedError(DvcException):
|
||||
|
||||
class RWLockFileFormatError(DvcException):
|
||||
def __init__(self, path):
|
||||
super().__init__(
|
||||
"RWLock-file '{}' format error.".format(relpath(path))
|
||||
)
|
||||
super().__init__(f"RWLock-file '{relpath(path)}' format error.")
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user