mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-02 23:09:28 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c33776eaee | ||
|
|
22781f5ef0 | ||
|
|
ae478ccdc0 | ||
|
|
87b49fd9f9 | ||
|
|
f90b9cdee1 | ||
|
|
40f24e5668 | ||
|
|
9d9ff10be1 | ||
|
|
98f7d1e481 | ||
|
|
9cf22d3edc | ||
|
|
4b6c6a0e79 | ||
|
|
6bccb21af6 | ||
|
|
beb6277d13 | ||
|
|
2485779d59 | ||
|
|
247b9c8be8 | ||
|
|
8d5f9ab6e5 | ||
|
|
07f5f6b633 | ||
|
|
aa255a1102 | ||
|
|
cfcb7e1294 | ||
|
|
b8d9800e3c | ||
|
|
fe46d50847 | ||
|
|
e3ff6d54a9 | ||
|
|
ce45ec8a28 | ||
|
|
cc5ba985b0 | ||
|
|
87cdbf8ace | ||
|
|
eda8cd5846 | ||
|
|
61835f6511 | ||
|
|
e72354e98e | ||
|
|
d1b36e36ca | ||
|
|
6ec66ab3de | ||
|
|
7c13897674 | ||
|
|
1db4637f2d | ||
|
|
ca3757b45b | ||
|
|
4e792ae61c | ||
|
|
6b13f76fdb | ||
|
|
53e9460c65 | ||
|
|
dafe6e991c | ||
|
|
09946847d2 | ||
|
|
6e5e62857b | ||
|
|
b7c6df0ece | ||
|
|
6779d3d431 | ||
|
|
5844a0cd5b | ||
|
|
ce3a3ab100 | ||
|
|
d1a219df0a | ||
|
|
476af100de | ||
|
|
f2998630a3 | ||
|
|
9a7b6d2a1a | ||
|
|
1d3524f144 | ||
|
|
1a5fa228ab | ||
|
|
adeb9ef63e | ||
|
|
3e93bc286f | ||
|
|
ea89f4a102 | ||
|
|
78299ad345 | ||
|
|
b9df4451b1 | ||
|
|
df67dc2db8 | ||
|
|
37fb73606c | ||
|
|
0deddf81cf | ||
|
|
429d54e146 | ||
|
|
32859763be | ||
|
|
5012494213 | ||
|
|
57c728269d | ||
|
|
3ca18cb400 | ||
|
|
1335c7f5e4 | ||
|
|
8b9b2f6907 | ||
|
|
2f8cf8d7d3 | ||
|
|
bf5c28b71a | ||
|
|
ed2a0b4282 | ||
|
|
eb46c03c6c | ||
|
|
5fb181fcf8 | ||
|
|
0e3db0d323 | ||
|
|
5e4de841d1 | ||
|
|
9e822edafc | ||
|
|
9d8b0b80a0 | ||
|
|
59cfc2c4a4 | ||
|
|
c7a3a000e0 | ||
|
|
782e2aba87 | ||
|
|
488a6f8076 | ||
|
|
1fbf0da3df | ||
|
|
bf5deb08e4 |
@@ -0,0 +1 @@
|
||||
* @iterative/dvc
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
types: [released, prereleased]
|
||||
jobs:
|
||||
deb:
|
||||
runs-on: ubuntu-16.04
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
- name: Set up Python 3.8
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
asset_name: dvc_${{ github.event.release.tag_name }}_amd64.deb
|
||||
asset_content_type: binary/octet-stream
|
||||
rpm:
|
||||
runs-on: ubuntu-16.04
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
- name: Set up Python 3.8
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
asset_name: dvc-${{ github.event.release.tag_name }}.exe
|
||||
asset_content_type: binary/octet-stream
|
||||
pip:
|
||||
runs-on: ubuntu-16.04
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
- name: Set up Python 3.7
|
||||
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
--cov-report=xml --cov-report=term
|
||||
--all ${{ env.extra_test_args }}
|
||||
- name: upload coverage report
|
||||
uses: codecov/[email protected].0
|
||||
uses: codecov/[email protected].2
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Slack Notification
|
||||
uses: rtCamp/action-slack-notify@v2.1.3
|
||||
uses: rtCamp/action-slack-notify@v2.2.0
|
||||
env:
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
SLACK_COLOR: ${{ job.status }}
|
||||
|
||||
+18
-3
@@ -1,14 +1,24 @@
|
||||
ci:
|
||||
skip: [pylint, dvc-pre-commit]
|
||||
|
||||
repos:
|
||||
- hooks:
|
||||
- id: black
|
||||
language_version: python3
|
||||
repo: https://github.com/ambv/black
|
||||
rev: 21.5b1
|
||||
rev: 21.6b0
|
||||
- hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --ignore-words-list
|
||||
- ba,datas,fo,uptodate
|
||||
repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.1.0
|
||||
- hooks:
|
||||
- id: isort
|
||||
language_version: python3
|
||||
repo: https://github.com/timothycrosley/isort
|
||||
rev: 5.8.0
|
||||
rev: 5.9.1
|
||||
- hooks:
|
||||
- id: flake8
|
||||
language_version: python3
|
||||
@@ -23,8 +33,13 @@ repos:
|
||||
- id: mypy
|
||||
language_version: python3
|
||||
files: ^dvc/
|
||||
additional_dependencies:
|
||||
- types-requests
|
||||
- types-paramiko
|
||||
- types-tabulate
|
||||
- types-toml
|
||||
repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: 'v0.812'
|
||||
rev: 'v0.910'
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pylint
|
||||
|
||||
+5
-1
@@ -184,7 +184,7 @@ Comparison to related technologies
|
||||
|
||||
#. *Data Engineering* tools such as `AirFlow <https://airflow.apache.org/>`_, `Luigi <https://github.com/spotify/luigi>`_ and
|
||||
others - in DVC data, model and ML pipelines represent a single ML project and focuses on data scientist' experience while
|
||||
data engineering pipeline orchestrates multiple data projects and focuses on efficent execution. DVC project can be used
|
||||
data engineering pipeline orchestrates multiple data projects and focuses on efficient execution. DVC project can be used
|
||||
from data pipeline as a single execution step. `AirFlow DVC <https://github.com/covid-genomics/airflow-dvc>`_ is an example
|
||||
of such integration.
|
||||
|
||||
@@ -204,6 +204,10 @@ Comparison to related technologies
|
||||
#. `DAGsHub <https://dagshub.com/>`_ - online service of Git+DVC repositories with pipeline and metrics visualization and
|
||||
DVC-specific cloud storage.
|
||||
|
||||
#. `DVC Studio <https://studio.iterative.ai/>`_ - online service of DVC repositories visualisation from DVC team. Also,
|
||||
integrated with `CML (CI/CD for ML) <https://cml.dev/>`_ for training models in clouds and Kubernetes.
|
||||
|
||||
|
||||
Contributing
|
||||
============
|
||||
|
||||
|
||||
+1
-1
@@ -134,7 +134,7 @@ def _cache_is_copy(cache, path_info):
|
||||
return True
|
||||
|
||||
workspace_file = path_info.with_name("." + uuid())
|
||||
test_cache_file = cache.fs.path_info / ".cache_type_test_file"
|
||||
test_cache_file = cache.path_info / ".cache_type_test_file"
|
||||
if not cache.fs.exists(test_cache_file):
|
||||
cache.makedirs(test_cache_file.parent)
|
||||
with cache.fs.open(test_cache_file, "wb") as fobj:
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
import os
|
||||
|
||||
from dvc.command.base import CmdBaseNoRepo, append_doc_link
|
||||
from dvc.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -71,7 +72,9 @@ class CmdConfig(CmdBaseNoRepo):
|
||||
prefix = self._config_file_prefix(
|
||||
self.args.show_origin, self.config, level
|
||||
)
|
||||
logger.info("\n".join(self._format_config(conf, prefix)))
|
||||
configs = list(self._format_config(conf, prefix))
|
||||
if configs:
|
||||
ui.write("\n".join(configs))
|
||||
|
||||
return 0
|
||||
|
||||
@@ -96,7 +99,7 @@ class CmdConfig(CmdBaseNoRepo):
|
||||
prefix = self._config_file_prefix(
|
||||
self.args.show_origin, self.config, level
|
||||
)
|
||||
logger.info("{}{}".format(prefix, conf[section][opt]))
|
||||
ui.write(prefix, conf[section][opt], sep="")
|
||||
break
|
||||
|
||||
return 0
|
||||
|
||||
@@ -9,10 +9,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class CmdDataBase(CmdBase):
|
||||
def log_summary(self, stats):
|
||||
from dvc.ui import ui
|
||||
from dvc.utils.humanize import get_summary
|
||||
|
||||
default_msg = "Everything is up to date."
|
||||
logger.info(get_summary(stats.items()) or default_msg)
|
||||
ui.write(get_summary(stats.items()) or default_msg)
|
||||
|
||||
|
||||
class CmdDataPull(CmdDataBase):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc import prompt
|
||||
from dvc.command.base import CmdBase, append_doc_link
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -10,6 +9,7 @@ logger = logging.getLogger(__name__)
|
||||
class CmdDestroy(CmdBase):
|
||||
def run(self):
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.ui import ui
|
||||
|
||||
try:
|
||||
statement = (
|
||||
@@ -19,7 +19,7 @@ class CmdDestroy(CmdBase):
|
||||
"Are you sure you want to continue?"
|
||||
)
|
||||
|
||||
if not self.args.force and not prompt.confirm(statement):
|
||||
if not self.args.force and not ui.confirm(statement):
|
||||
raise DvcException(
|
||||
"cannot destroy without a confirmation from the user."
|
||||
" Use `-f` to force."
|
||||
|
||||
+20
-34
@@ -3,10 +3,9 @@ import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import colorama
|
||||
|
||||
from dvc.command import completion
|
||||
from dvc.command.base import CmdBase, append_doc_link
|
||||
from dvc.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,13 +17,12 @@ def _digest(checksum):
|
||||
|
||||
|
||||
def _show_md(diff, show_hash=False, hide_missing=False):
|
||||
from dvc.ui import ui
|
||||
|
||||
headers = ["Status", "Hash", "Path"] if show_hash else ["Status", "Path"]
|
||||
rows = []
|
||||
statuses = ["added", "deleted", "renamed", "modified"]
|
||||
if not hide_missing:
|
||||
statuses.append("not in cache")
|
||||
|
||||
for status in statuses:
|
||||
entries = diff.get(status, [])
|
||||
if not entries:
|
||||
@@ -44,7 +42,7 @@ def _show_md(diff, show_hash=False, hide_missing=False):
|
||||
|
||||
class CmdDiff(CmdBase):
|
||||
@staticmethod
|
||||
def _format(diff, hide_missing=False):
|
||||
def _show_diff(diff, hide_missing=False):
|
||||
"""
|
||||
Given a diff structure, generate a string of paths separated
|
||||
by new lines and grouped together by their state.
|
||||
@@ -71,15 +69,14 @@ class CmdDiff(CmdBase):
|
||||
"""
|
||||
|
||||
colors = {
|
||||
"added": colorama.Fore.GREEN,
|
||||
"modified": colorama.Fore.YELLOW,
|
||||
"deleted": colorama.Fore.RED,
|
||||
"renamed": colorama.Fore.GREEN,
|
||||
"not in cache": colorama.Fore.YELLOW,
|
||||
"added": "green",
|
||||
"modified": "yellow",
|
||||
"deleted": "red",
|
||||
"renamed": "green",
|
||||
"not in cache": "yellow",
|
||||
}
|
||||
|
||||
summary = {}
|
||||
groups = []
|
||||
|
||||
states = ["added", "deleted", "renamed", "modified"]
|
||||
if not hide_missing:
|
||||
@@ -91,7 +88,8 @@ class CmdDiff(CmdBase):
|
||||
if not entries:
|
||||
continue
|
||||
|
||||
content = []
|
||||
header = state.capitalize()
|
||||
ui.write(f"[{colors[state]}]{header}[/]:", styled=True)
|
||||
|
||||
for entry in entries:
|
||||
path = entry["path"]
|
||||
@@ -99,7 +97,7 @@ class CmdDiff(CmdBase):
|
||||
path = f"{path['old']} -> {path['new']}"
|
||||
checksum = entry.get("hash")
|
||||
summary[state] += 1 if not path.endswith(os.sep) else 0
|
||||
content.append(
|
||||
ui.write(
|
||||
"{space}{checksum}{separator}{path}".format(
|
||||
space=" ",
|
||||
checksum=_digest(checksum) if checksum else "",
|
||||
@@ -108,27 +106,17 @@ class CmdDiff(CmdBase):
|
||||
)
|
||||
)
|
||||
|
||||
groups.append(
|
||||
"{color}{header}{nc}:\n{content}".format(
|
||||
color=colors[state],
|
||||
header=state.capitalize(),
|
||||
nc=colorama.Fore.RESET,
|
||||
content="\n".join(content),
|
||||
)
|
||||
)
|
||||
ui.write()
|
||||
|
||||
if not sum(summary.values()):
|
||||
return None
|
||||
|
||||
fmt = (
|
||||
"files summary: {added} added, {deleted} deleted,"
|
||||
" {renamed} renamed, {modified} modified"
|
||||
states_summary = ", ".join(
|
||||
f"{summary[state]} {state}"
|
||||
for state in states
|
||||
if summary[state] > 0
|
||||
)
|
||||
if not hide_missing:
|
||||
fmt += ", {not in cache} not in cache"
|
||||
groups.append(fmt.format_map(summary))
|
||||
|
||||
return "\n\n".join(groups)
|
||||
ui.write("files summary:", states_summary)
|
||||
|
||||
def run(self):
|
||||
from dvc.exceptions import DvcException
|
||||
@@ -155,13 +143,11 @@ class CmdDiff(CmdBase):
|
||||
diff[key] = entries
|
||||
|
||||
if self.args.show_json:
|
||||
logger.info(json.dumps(diff))
|
||||
ui.write(json.dumps(diff))
|
||||
elif self.args.show_md:
|
||||
logger.info(_show_md(diff, show_hash, hide_missing))
|
||||
_show_md(diff, show_hash, hide_missing)
|
||||
elif diff:
|
||||
output = self._format(diff, hide_missing)
|
||||
if output:
|
||||
logger.info(output)
|
||||
self._show_diff(diff, hide_missing)
|
||||
|
||||
except DvcException:
|
||||
logger.exception("failed to get diff")
|
||||
|
||||
+26
-28
@@ -14,6 +14,7 @@ from dvc.command.metrics import DEFAULT_PRECISION
|
||||
from dvc.command.repro import CmdRepro
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -396,7 +397,7 @@ def show_experiments(
|
||||
styles.update(
|
||||
{
|
||||
header: {
|
||||
"justify": "left" if typ == "metrics" else "params",
|
||||
"justify": "right" if typ == "metrics" else "left",
|
||||
"header_style": f"black on {header_bg_colors[typ]}",
|
||||
"collapse": idx != 0,
|
||||
"no_wrap": typ == "metrics",
|
||||
@@ -450,7 +451,7 @@ class CmdExperimentsShow(CmdBase):
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
logger.info(json.dumps(all_experiments, default=_format_json))
|
||||
ui.write(json.dumps(all_experiments, default=_format_json))
|
||||
else:
|
||||
show_experiments(
|
||||
all_experiments,
|
||||
@@ -494,7 +495,7 @@ class CmdExperimentsDiff(CmdBase):
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
logger.info(json.dumps(diff))
|
||||
ui.write(json.dumps(diff))
|
||||
else:
|
||||
from dvc.compare import show_diff
|
||||
|
||||
@@ -502,8 +503,6 @@ class CmdExperimentsDiff(CmdBase):
|
||||
diffs = [("metrics", "Metric"), ("params", "Param")]
|
||||
for idx, (key, title) in enumerate(diffs):
|
||||
if idx:
|
||||
from dvc.ui import ui
|
||||
|
||||
# we are printing tables even in `--quiet` mode
|
||||
# so we should also be printing the "table" separator
|
||||
ui.write(force=True)
|
||||
@@ -537,7 +536,7 @@ class CmdExperimentsRun(CmdRepro):
|
||||
)
|
||||
|
||||
if self.args.reset:
|
||||
logger.info("Any existing checkpoints will be reset and re-run.")
|
||||
ui.write("Any existing checkpoints will be reset and re-run.")
|
||||
|
||||
results = self.repo.experiments.run(
|
||||
name=self.args.name,
|
||||
@@ -554,7 +553,7 @@ class CmdExperimentsRun(CmdRepro):
|
||||
if self.args.metrics and results:
|
||||
metrics = self.repo.metrics.show(revs=list(results))
|
||||
metrics.pop("workspace", None)
|
||||
logger.info(show_metrics(metrics))
|
||||
show_metrics(metrics)
|
||||
|
||||
return 0
|
||||
|
||||
@@ -608,12 +607,13 @@ class CmdExperimentsGC(CmdRepro):
|
||||
)
|
||||
|
||||
if removed:
|
||||
logger.info(
|
||||
f"Removed {removed} experiments. To remove unused cache files "
|
||||
"use 'dvc gc'."
|
||||
ui.write(
|
||||
f"Removed {removed} experiments.",
|
||||
"To remove unused cache files",
|
||||
"use 'dvc gc'.",
|
||||
)
|
||||
else:
|
||||
logger.info("No experiments to remove.")
|
||||
ui.write("No experiments to remove.")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -662,15 +662,15 @@ class CmdExperimentsPush(CmdBase):
|
||||
run_cache=self.args.run_cache,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Pushed experiment '%s' to Git remote '%s'.",
|
||||
self.args.experiment,
|
||||
self.args.git_remote,
|
||||
ui.write(
|
||||
f"Pushed experiment '{self.args.experiment}'"
|
||||
f"to Git remote '{self.args.git_remote}'."
|
||||
)
|
||||
if not self.args.push_cache:
|
||||
logger.info(
|
||||
"To push cached outputs for this experiment to DVC remote "
|
||||
"storage, re-run this command without '--no-cache'."
|
||||
ui.write(
|
||||
"To push cached outputs",
|
||||
"for this experiment to DVC remote storage,"
|
||||
"re-run this command without '--no-cache'.",
|
||||
)
|
||||
|
||||
return 0
|
||||
@@ -689,15 +689,15 @@ class CmdExperimentsPull(CmdBase):
|
||||
run_cache=self.args.run_cache,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Pulled experiment '%s' from Git remote '%s'. ",
|
||||
self.args.experiment,
|
||||
self.args.git_remote,
|
||||
ui.write(
|
||||
f"Pulled experiment '{self.args.experiment}'",
|
||||
f"from Git remote '{self.args.git_remote}'.",
|
||||
)
|
||||
if not self.args.pull_cache:
|
||||
logger.info(
|
||||
"To pull cached outputs for this experiment from DVC remote "
|
||||
"storage, re-run this command without '--no-cache'."
|
||||
ui.write(
|
||||
"To pull cached outputs for this experiment"
|
||||
"from DVC remote storage,"
|
||||
"re-run this command without '--no-cache'."
|
||||
)
|
||||
|
||||
return 0
|
||||
@@ -937,9 +937,7 @@ def add_parser(subparsers, parent_parser):
|
||||
)
|
||||
experiments_diff_parser.set_defaults(func=CmdExperimentsDiff)
|
||||
|
||||
EXPERIMENTS_RUN_HELP = (
|
||||
"Reproduce complete or partial experiment pipelines."
|
||||
)
|
||||
EXPERIMENTS_RUN_HELP = "Run or resume an experiment."
|
||||
experiments_run_parser = experiments_subparsers.add_parser(
|
||||
"run",
|
||||
parents=[parent_parser],
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@ import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
from dvc import prompt
|
||||
from dvc.command.base import CmdBase, append_doc_link
|
||||
from dvc.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,7 +44,7 @@ class CmdGC(CmdBase):
|
||||
logger.warning(msg)
|
||||
|
||||
msg = "Are you sure you want to proceed?"
|
||||
if not self.args.force and not prompt.confirm(msg):
|
||||
if not self.args.force and not ui.confirm(msg):
|
||||
return 1
|
||||
|
||||
self.repo.gc(
|
||||
|
||||
+2
-1
@@ -12,12 +12,13 @@ logger = logging.getLogger(__name__)
|
||||
class CmdGet(CmdBaseNoRepo):
|
||||
def _show_url(self):
|
||||
from dvc.api import get_url
|
||||
from dvc.ui import ui
|
||||
|
||||
try:
|
||||
url = get_url(
|
||||
self.args.path, repo=self.args.url, rev=self.args.rev
|
||||
)
|
||||
logger.info(url)
|
||||
ui.write(url, force=True)
|
||||
except DvcException:
|
||||
logger.exception("failed to show URL")
|
||||
return 1
|
||||
|
||||
+4
-2
@@ -12,8 +12,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _welcome_message():
|
||||
from dvc.ui import ui
|
||||
|
||||
if analytics.is_enabled():
|
||||
logger.info(
|
||||
ui.write(
|
||||
boxify(
|
||||
"DVC has enabled anonymous aggregate usage analytics.\n"
|
||||
"Read the analytics documentation (and how to opt-out) here:\n"
|
||||
@@ -30,7 +32,7 @@ def _welcome_message():
|
||||
f"- Star us on GitHub: {fmt_link('https://github.com/iterative/dvc')}"
|
||||
).format(yellow=colorama.Fore.YELLOW, nc=colorama.Fore.RESET)
|
||||
|
||||
logger.info(msg)
|
||||
ui.write(msg)
|
||||
|
||||
|
||||
class CmdInit(CmdBaseNoRepo):
|
||||
|
||||
+2
-4
@@ -1,11 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
from dvc.command import completion
|
||||
from dvc.command.base import CmdBase, fix_subparsers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from dvc.ui import ui
|
||||
|
||||
|
||||
class CmdLive(CmdBase):
|
||||
@@ -17,7 +15,7 @@ class CmdLive(CmdBase):
|
||||
html_path = self.args.target + ".html"
|
||||
self.repo.plots.write_html(html_path, plots, metrics)
|
||||
|
||||
logger.info(f"\nfile://{os.path.abspath(html_path)}")
|
||||
ui.write("\nfile://", os.path.abspath(html_path), sep="")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,7 +33,7 @@ class CmdMetricsShow(CmdMetricsBase):
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
logger.info(json.dumps(metrics))
|
||||
ui.write(json.dumps(metrics))
|
||||
else:
|
||||
from dvc.compare import show_metrics
|
||||
|
||||
@@ -73,7 +74,7 @@ class CmdMetricsDiff(CmdMetricsBase):
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
logger.info(json.dumps(diff))
|
||||
ui.write(json.dumps(diff))
|
||||
else:
|
||||
from dvc.compare import show_diff
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from dvc.command import completion
|
||||
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,7 +28,7 @@ class CmdParamsDiff(CmdBase):
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
logger.info(json.dumps(diff))
|
||||
ui.write(json.dumps(diff))
|
||||
else:
|
||||
from dvc.compare import show_diff
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from dvc.command import completion
|
||||
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.ui import ui
|
||||
from dvc.utils import format_link
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,7 +39,7 @@ class CmdPlots(CmdBase):
|
||||
|
||||
if self.args.show_vega:
|
||||
target = self.args.targets[0]
|
||||
logger.info(plots[target])
|
||||
ui.write(plots[target])
|
||||
return 0
|
||||
|
||||
rel: str = self.args.out or "plots.html"
|
||||
@@ -53,13 +54,15 @@ class CmdPlots(CmdBase):
|
||||
|
||||
assert path.is_absolute() # as_uri throws ValueError if not absolute
|
||||
url = path.as_uri()
|
||||
logger.info(url)
|
||||
ui.write(url)
|
||||
if self.args.open:
|
||||
import webbrowser
|
||||
|
||||
opened = webbrowser.open(rel)
|
||||
if not opened:
|
||||
logger.error("Failed to open. Please try opening it manually.")
|
||||
ui.error_write(
|
||||
"Failed to open. Please try opening it manually."
|
||||
)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.command.base import append_doc_link, fix_subparsers
|
||||
from dvc.command.config import CmdConfig
|
||||
from dvc.ui import ui
|
||||
from dvc.utils import format_link
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdRemote(CmdConfig):
|
||||
def __init__(self, args):
|
||||
@@ -27,7 +25,7 @@ class CmdRemoteAdd(CmdRemote):
|
||||
from dvc.config import ConfigError
|
||||
|
||||
if self.args.default:
|
||||
logger.info(f"Setting '{self.args.name}' as a default remote.")
|
||||
ui.write(f"Setting '{self.args.name}' as a default remote.")
|
||||
|
||||
with self.config.edit(self.args.level) as conf:
|
||||
if self.args.name in conf["remote"] and not self.args.force:
|
||||
@@ -90,7 +88,7 @@ class CmdRemoteDefault(CmdRemote):
|
||||
try:
|
||||
print(conf["core"]["remote"])
|
||||
except KeyError:
|
||||
logger.info("No default remote set")
|
||||
ui.write("No default remote set")
|
||||
return 1
|
||||
else:
|
||||
with self.config.edit(self.args.level) as conf:
|
||||
@@ -116,7 +114,7 @@ class CmdRemoteList(CmdRemote):
|
||||
def run(self):
|
||||
conf = self.config.read(self.args.level)
|
||||
for name, conf in conf["remote"].items():
|
||||
logger.info("{}\t{}".format(name, conf["url"]))
|
||||
ui.write(name, conf["url"], sep="\t")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.command import completion
|
||||
from dvc.command.base import CmdBase, append_doc_link
|
||||
from dvc.command.status import CmdDataStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdRepro(CmdBase):
|
||||
def run(self):
|
||||
from dvc.ui import ui
|
||||
|
||||
stages = self.repo.reproduce(**self._repro_kwargs)
|
||||
if len(stages) == 0:
|
||||
logger.info(CmdDataStatus.UP_TO_DATE_MSG)
|
||||
ui.write(CmdDataStatus.UP_TO_DATE_MSG)
|
||||
else:
|
||||
logger.info(
|
||||
ui.write(
|
||||
"Use `dvc push` to send your updates to " "remote storage."
|
||||
)
|
||||
|
||||
|
||||
+2
-1
@@ -10,8 +10,9 @@ logger = logging.getLogger(__name__)
|
||||
class CmdRoot(CmdBaseNoRepo):
|
||||
def run(self):
|
||||
from dvc.repo import Repo
|
||||
from dvc.ui import ui
|
||||
|
||||
logger.info(relpath(Repo.find_root()))
|
||||
ui.write(relpath(Repo.find_root()))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
|
||||
from dvc.command.data_sync import CmdDataBase
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.ui import ui
|
||||
from dvc.utils import format_link
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,7 +27,7 @@ class CmdDataStatus(CmdDataBase):
|
||||
ind = indent * self.STATUS_INDENT
|
||||
|
||||
if isinstance(status, str):
|
||||
logger.info(f"{ind}{status}")
|
||||
ui.write(f"{ind}{status}")
|
||||
return
|
||||
|
||||
if isinstance(status, list):
|
||||
@@ -38,14 +39,13 @@ class CmdDataStatus(CmdDataBase):
|
||||
|
||||
for key, value in status.items():
|
||||
if isinstance(value, str):
|
||||
logger.info("{}{}{}".format(ind, self._normalize(value), key))
|
||||
ui.write("{}{}{}".format(ind, self._normalize(value), key))
|
||||
elif value:
|
||||
logger.info(f"{ind}{key}:")
|
||||
ui.write(f"{ind}{key}:")
|
||||
self._show(value, indent + 1)
|
||||
|
||||
def run(self):
|
||||
from dvc.repo import lock_repo
|
||||
from dvc.ui import ui
|
||||
|
||||
indent = 1 if self.args.cloud else 0
|
||||
|
||||
@@ -72,7 +72,7 @@ class CmdDataStatus(CmdDataBase):
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
logger.info(json.dumps(st))
|
||||
ui.write(json.dumps(st))
|
||||
return 0
|
||||
|
||||
if st:
|
||||
|
||||
@@ -2,6 +2,7 @@ import argparse
|
||||
import logging
|
||||
|
||||
from dvc.command.base import CmdBaseNoRepo, append_doc_link
|
||||
from dvc.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -11,7 +12,7 @@ class CmdVersion(CmdBaseNoRepo):
|
||||
from dvc.info import get_dvc_info
|
||||
|
||||
dvc_info = get_dvc_info()
|
||||
logger.info(dvc_info)
|
||||
ui.write(dvc_info, force=True)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ HTTP_COMMON = {
|
||||
"user": str,
|
||||
"password": str,
|
||||
"ask_password": Bool,
|
||||
"ssl_verify": Bool,
|
||||
"ssl_verify": Any(Bool, str),
|
||||
"method": str,
|
||||
}
|
||||
WEBDAV_COMMON = {
|
||||
@@ -104,6 +104,7 @@ WEBDAV_COMMON = {
|
||||
"cert_path": str,
|
||||
"key_path": str,
|
||||
"timeout": Coerce(int),
|
||||
"ssl_verify": Any(Bool, str),
|
||||
}
|
||||
|
||||
SCHEMA = {
|
||||
@@ -145,7 +146,7 @@ SCHEMA = {
|
||||
"session_token": str,
|
||||
Optional("listobjects", default=False): Bool, # obsoleted
|
||||
Optional("use_ssl", default=True): Bool,
|
||||
Optional("ssl_verify", default=True): Bool,
|
||||
"ssl_verify": Any(Bool, str),
|
||||
"sse": str,
|
||||
"sse_kms_key_id": str,
|
||||
"acl": str,
|
||||
@@ -188,6 +189,7 @@ SCHEMA = {
|
||||
"tenant_id": str,
|
||||
"client_id": str,
|
||||
"client_secret": str,
|
||||
"allow_anonymous_login": bool,
|
||||
**REMOTE_COMMON,
|
||||
},
|
||||
"oss": {
|
||||
|
||||
+10
-10
@@ -163,7 +163,7 @@ class AsciiCanvas:
|
||||
self.point(x0 + width, y0 + height, "+")
|
||||
|
||||
|
||||
def _build_sugiyama_layout(vertexes, edges):
|
||||
def _build_sugiyama_layout(vertices, edges):
|
||||
#
|
||||
# Just a reminder about naming conventions:
|
||||
# +------------X
|
||||
@@ -174,17 +174,17 @@ def _build_sugiyama_layout(vertexes, edges):
|
||||
# Y
|
||||
#
|
||||
|
||||
vertexes = {v: Vertex(f" {v} ") for v in vertexes}
|
||||
vertices = {v: Vertex(f" {v} ") for v in vertices}
|
||||
# NOTE: reverting edges to correctly orientate the graph
|
||||
edges = [Edge(vertexes[e], vertexes[s]) for s, e in edges]
|
||||
vertexes = vertexes.values()
|
||||
graph = Graph(vertexes, edges)
|
||||
edges = [Edge(vertices[e], vertices[s]) for s, e in edges]
|
||||
vertices = vertices.values()
|
||||
graph = Graph(vertices, edges)
|
||||
|
||||
for vertex in vertexes:
|
||||
for vertex in vertices:
|
||||
vertex.view = VertexViewer(vertex.data)
|
||||
|
||||
# NOTE: determine min box length to create the best layout
|
||||
minw = min(v.view.w for v in vertexes)
|
||||
minw = min(v.view.w for v in vertices)
|
||||
|
||||
for edge in edges:
|
||||
edge.view = EdgeViewer()
|
||||
@@ -204,11 +204,11 @@ def _build_sugiyama_layout(vertexes, edges):
|
||||
return sug
|
||||
|
||||
|
||||
def draw(vertexes, edges):
|
||||
def draw(vertices, edges):
|
||||
"""Build a DAG and draw it in ASCII.
|
||||
|
||||
Args:
|
||||
vertexes (list): list of graph vertexes.
|
||||
vertices (list): list of graph vertices.
|
||||
edges (list): list of graph edges.
|
||||
"""
|
||||
# pylint: disable=too-many-locals
|
||||
@@ -217,7 +217,7 @@ def draw(vertexes, edges):
|
||||
Xs = [] # pylint: disable=invalid-name
|
||||
Ys = [] # pylint: disable=invalid-name
|
||||
|
||||
sug = _build_sugiyama_layout(vertexes, edges)
|
||||
sug = _build_sugiyama_layout(vertices, edges)
|
||||
|
||||
for vertex in sug.g.sV:
|
||||
# NOTE: moving boxes w/2 to the left
|
||||
|
||||
+53
-32
@@ -1,6 +1,11 @@
|
||||
"""Manages dvc remotes that user can use with push/pull/status commands."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Iterable, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.objects.file import HashFile
|
||||
from dvc.remote.base import Remote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,7 +24,11 @@ class DataCloud:
|
||||
def __init__(self, repo):
|
||||
self.repo = repo
|
||||
|
||||
def get_remote(self, name=None, command="<command>"):
|
||||
def get_remote(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
command: str = "<command>",
|
||||
) -> "Remote":
|
||||
from dvc.config import NoRemoteError
|
||||
|
||||
if not name:
|
||||
@@ -48,76 +57,88 @@ class DataCloud:
|
||||
|
||||
return get_remote(self.repo, name=name)
|
||||
|
||||
def push(self, cache, jobs=None, remote=None, show_checksums=False):
|
||||
def push(
|
||||
self,
|
||||
objs: Iterable["HashFile"],
|
||||
jobs: Optional[int] = None,
|
||||
remote: Optional[str] = None,
|
||||
show_checksums: bool = False,
|
||||
):
|
||||
"""Push data items in a cloud-agnostic way.
|
||||
|
||||
Args:
|
||||
cache (NamedCache): named checksums to push to the cloud.
|
||||
jobs (int): number of jobs that can be running simultaneously.
|
||||
remote (dvc.remote.base.BaseRemote): optional remote to push to.
|
||||
objs: objects to push to the cloud.
|
||||
jobs: number of jobs that can be running simultaneously.
|
||||
remote: optional remote to push to.
|
||||
By default remote from core.remote config option is used.
|
||||
show_checksums (bool): show checksums instead of file names in
|
||||
show_checksums: show checksums instead of file names in
|
||||
information messages.
|
||||
"""
|
||||
remote = self.get_remote(remote, "push")
|
||||
remote_obj = self.get_remote(remote, "push")
|
||||
|
||||
return remote.push(
|
||||
return remote_obj.push(
|
||||
self.repo.odb.local,
|
||||
cache,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
)
|
||||
|
||||
def pull(self, cache, jobs=None, remote=None, show_checksums=False):
|
||||
def pull(
|
||||
self,
|
||||
objs: Iterable["HashFile"],
|
||||
jobs: Optional[int] = None,
|
||||
remote: Optional[str] = None,
|
||||
show_checksums: bool = False,
|
||||
):
|
||||
"""Pull data items in a cloud-agnostic way.
|
||||
|
||||
Args:
|
||||
cache (NamedCache): named checksums to pull from the cloud.
|
||||
jobs (int): number of jobs that can be running simultaneously.
|
||||
remote (dvc.remote.base.BaseRemote): optional remote to pull from.
|
||||
objs: objects to pull from the cloud.
|
||||
jobs: number of jobs that can be running simultaneously.
|
||||
remote: optional remote to pull from.
|
||||
By default remote from core.remote config option is used.
|
||||
show_checksums (bool): show checksums instead of file names in
|
||||
show_checksums: show checksums instead of file names in
|
||||
information messages.
|
||||
"""
|
||||
remote = self.get_remote(remote, "pull")
|
||||
remote_obj = self.get_remote(remote, "pull")
|
||||
|
||||
return remote.pull(
|
||||
return remote_obj.pull(
|
||||
self.repo.odb.local,
|
||||
cache,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
)
|
||||
|
||||
def status(
|
||||
self,
|
||||
cache,
|
||||
jobs=None,
|
||||
remote=None,
|
||||
show_checksums=False,
|
||||
log_missing=True,
|
||||
objs: Iterable["HashFile"],
|
||||
jobs: Optional[int] = None,
|
||||
remote: Optional[str] = None,
|
||||
show_checksums: bool = False,
|
||||
log_missing: bool = True,
|
||||
):
|
||||
"""Check status of data items in a cloud-agnostic way.
|
||||
|
||||
Args:
|
||||
cache (NamedCache): named checksums to check status for.
|
||||
jobs (int): number of jobs that can be running simultaneously.
|
||||
remote (dvc.remote.base.BaseRemote): optional remote to compare
|
||||
objs: objects to check status for.
|
||||
jobs: number of jobs that can be running simultaneously.
|
||||
remote: optional remote to compare
|
||||
cache to. By default remote from core.remote config option
|
||||
is used.
|
||||
show_checksums (bool): show checksums instead of file names in
|
||||
show_checksums: show checksums instead of file names in
|
||||
information messages.
|
||||
log_missing (bool): log warning messages if file doesn't exist
|
||||
log_missing: log warning messages if file doesn't exist
|
||||
neither in cache, neither in cloud.
|
||||
"""
|
||||
remote = self.get_remote(remote, "status")
|
||||
return remote.status(
|
||||
remote_obj = self.get_remote(remote, "status")
|
||||
return remote_obj.status(
|
||||
self.repo.odb.local,
|
||||
cache,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
log_missing=log_missing,
|
||||
)
|
||||
|
||||
def get_url_for(self, remote, checksum):
|
||||
remote = self.get_remote(remote)
|
||||
return str(remote.odb.hash_to_path_info(checksum))
|
||||
remote_obj = self.get_remote(remote)
|
||||
return str(remote_obj.odb.hash_to_path_info(checksum))
|
||||
|
||||
+116
-46
@@ -1,9 +1,17 @@
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Set
|
||||
|
||||
from voluptuous import Required
|
||||
|
||||
from dvc.path_info import PathInfo
|
||||
|
||||
from .base import Dependency
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.objects.db.base import ObjectDB
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
|
||||
class RepoDependency(Dependency):
|
||||
PARAM_REPO = "repo"
|
||||
@@ -21,6 +29,7 @@ class RepoDependency(Dependency):
|
||||
|
||||
def __init__(self, def_repo, stage, *args, **kwargs):
|
||||
self.def_repo = def_repo
|
||||
self._staged_objs: Dict[str, "HashFile"] = {}
|
||||
super().__init__(stage, *args, **kwargs)
|
||||
|
||||
def _parse_path(self, fs, path_info):
|
||||
@@ -30,37 +39,12 @@ class RepoDependency(Dependency):
|
||||
def is_in_repo(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def repo_pair(self):
|
||||
d = self.def_repo
|
||||
rev = d.get(self.PARAM_REV_LOCK) or d.get(self.PARAM_REV)
|
||||
return d[self.PARAM_URL], rev
|
||||
|
||||
def __str__(self):
|
||||
return "{} ({})".format(self.def_path, self.def_repo[self.PARAM_URL])
|
||||
|
||||
def _make_repo(self, *, locked=True, **kwargs):
|
||||
from dvc.external_repo import external_repo
|
||||
|
||||
d = self.def_repo
|
||||
rev = (d.get("rev_lock") if locked else None) or d.get("rev")
|
||||
return external_repo(d["url"], rev=rev, **kwargs)
|
||||
|
||||
def _get_hash(self, locked=True):
|
||||
from dvc.objects.stage import stage
|
||||
|
||||
with self._make_repo(locked=locked) as repo:
|
||||
path_info = PathInfo(repo.root_dir) / self.def_path
|
||||
return stage(
|
||||
self.repo.odb.local,
|
||||
path_info,
|
||||
repo.repo_fs,
|
||||
self.repo.odb.local.fs.PARAM_CHECKSUM,
|
||||
).hash_info
|
||||
|
||||
def workspace_status(self):
|
||||
current = self._get_hash(locked=True)
|
||||
updated = self._get_hash(locked=False)
|
||||
current = self.get_obj(locked=True).hash_info
|
||||
updated = self.get_obj(locked=False).hash_info
|
||||
|
||||
if current != updated:
|
||||
return {str(self): "update available"}
|
||||
@@ -78,31 +62,21 @@ class RepoDependency(Dependency):
|
||||
|
||||
def download(self, to, jobs=None):
|
||||
from dvc.checkout import checkout
|
||||
from dvc.config import NoRemoteError
|
||||
from dvc.exceptions import NoOutputOrStageError
|
||||
from dvc.objects import save
|
||||
from dvc.objects.stage import stage
|
||||
from dvc.objects.db.git import GitObjectDB
|
||||
from dvc.repo.fetch import fetch_from_odb
|
||||
|
||||
odb = self.repo.odb.local
|
||||
|
||||
with self._make_repo(cache_dir=odb.cache_dir) as repo:
|
||||
if self.def_repo.get(self.PARAM_REV_LOCK) is None:
|
||||
self.def_repo[self.PARAM_REV_LOCK] = repo.get_rev()
|
||||
path_info = PathInfo(repo.root_dir) / self.def_path
|
||||
try:
|
||||
repo.fetch([path_info.fspath], jobs=jobs, recursive=True)
|
||||
except (NoOutputOrStageError, NoRemoteError):
|
||||
pass
|
||||
obj = stage(
|
||||
odb, path_info, repo.repo_fs, odb.fs.PARAM_CHECKSUM, jobs=jobs
|
||||
)
|
||||
save(odb, obj, jobs=jobs)
|
||||
for odb, objs in self.get_used_objs().items():
|
||||
if not isinstance(odb, GitObjectDB):
|
||||
fetch_from_odb(self.repo, odb, objs, jobs=jobs)
|
||||
|
||||
obj = self.get_obj()
|
||||
save(self.repo.odb.local, obj, jobs=jobs)
|
||||
checkout(
|
||||
to.path_info,
|
||||
to.fs,
|
||||
obj,
|
||||
odb,
|
||||
self.repo.odb.local,
|
||||
dvcignore=None,
|
||||
state=self.repo.state,
|
||||
)
|
||||
@@ -110,7 +84,6 @@ class RepoDependency(Dependency):
|
||||
def update(self, rev=None):
|
||||
if rev:
|
||||
self.def_repo[self.PARAM_REV] = rev
|
||||
|
||||
with self._make_repo(locked=False) as repo:
|
||||
self.def_repo[self.PARAM_REV_LOCK] = repo.get_rev()
|
||||
|
||||
@@ -119,3 +92,100 @@ class RepoDependency(Dependency):
|
||||
# origin project url and rev_lock, and it makes RepoDependency
|
||||
# immutable, hence its impossible for checksum to change.
|
||||
return False
|
||||
|
||||
def get_used_objs(
|
||||
self, **kwargs
|
||||
) -> Dict[Optional["ObjectDB"], Set["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
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
try:
|
||||
staged_obj = stage(
|
||||
local_odb,
|
||||
path_info,
|
||||
repo.repo_fs,
|
||||
local_odb.fs.PARAM_CHECKSUM,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise PathMissingError(
|
||||
self.def_path, self.def_repo[self.PARAM_URL]
|
||||
) from exc
|
||||
|
||||
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
|
||||
|
||||
def _check_circular_import(self, odb):
|
||||
from dvc.exceptions import CircularImportError
|
||||
from dvc.fs.repo import RepoFileSystem
|
||||
|
||||
if not odb or not isinstance(odb.fs, RepoFileSystem):
|
||||
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 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
|
||||
|
||||
def _make_repo(self, locked=True, **kwargs):
|
||||
from dvc.external_repo import external_repo
|
||||
|
||||
d = self.def_repo
|
||||
rev = self._get_rev(locked=locked)
|
||||
return external_repo(d[self.PARAM_URL], rev=rev, **kwargs)
|
||||
|
||||
def _get_rev(self, locked=True):
|
||||
d = self.def_repo
|
||||
return (d.get(self.PARAM_REV_LOCK) if locked else None) or d.get(
|
||||
self.PARAM_REV
|
||||
)
|
||||
|
||||
@@ -6,3 +6,4 @@ DVCLIVE_PATH = "DVCLIVE_PATH"
|
||||
DVCLIVE_SUMMARY = "DVCLIVE_SUMMARY"
|
||||
DVCLIVE_HTML = "DVCLIVE_HTML"
|
||||
DVCLIVE_RESUME = "DVCLIVE_RESUME"
|
||||
DVC_IGNORE_ISATTY = "DVC_IGNORE_ISATTY"
|
||||
|
||||
@@ -384,3 +384,11 @@ class CacheLinkError(DvcException):
|
||||
)
|
||||
super().__init__(msg)
|
||||
self.path_infos = path_infos
|
||||
|
||||
|
||||
class CircularImportError(DvcException):
|
||||
def __init__(self, dep, a, b):
|
||||
super().__init__(
|
||||
f"'{dep}' contains invalid circular import. "
|
||||
f"DVC repo '{a}' already imports from '{b}'."
|
||||
)
|
||||
|
||||
+9
-3
@@ -12,8 +12,7 @@ from .local import LocalFileSystem
|
||||
from .oss import OSSFileSystem
|
||||
from .s3 import S3FileSystem
|
||||
from .ssh import SSHFileSystem
|
||||
from .webdav import WebDAVFileSystem
|
||||
from .webdavs import WebDAVSFileSystem
|
||||
from .webdav import WebDAVFileSystem, WebDAVSFileSystem
|
||||
from .webhdfs import WebHDFSFileSystem
|
||||
|
||||
FS_MAP = {
|
||||
@@ -106,4 +105,11 @@ def get_cloud_fs(repo, **kwargs):
|
||||
if cls == GDriveFileSystem and repo:
|
||||
remote_conf["gdrive_credentials_tmp_dir"] = repo.tmp_dir
|
||||
|
||||
return cls, remote_conf
|
||||
url = remote_conf.pop("url")
|
||||
path_info = cls.PATH_CLS(
|
||||
cls._strip_protocol(url) # pylint:disable=protected-access
|
||||
)
|
||||
|
||||
extras = cls._get_kwargs_from_urls(url) # pylint:disable=protected-access
|
||||
conf = {**extras, **remote_conf} # remote config takes priority
|
||||
return cls, conf, path_info
|
||||
|
||||
+47
-34
@@ -5,7 +5,8 @@ import sys
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
from fsspec.utils import infer_storage_options
|
||||
from funcy import cached_property, memoize, wrap_prop
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.path_info import CloudURLInfo
|
||||
@@ -24,7 +25,7 @@ _DEFAULT_CREDS_STEPS = (
|
||||
|
||||
@contextmanager
|
||||
def _temp_event_loop():
|
||||
"""When trying to initalize azure filesystem instances
|
||||
"""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
|
||||
@@ -67,6 +68,19 @@ class AzureAuthError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
@memoize
|
||||
def _az_config():
|
||||
# NOTE: ideally we would've used get_default_cli().config from
|
||||
# azure.cli.core, but azure-cli-core has a lot of conflicts with other
|
||||
# dependencies. So instead we are just use knack directly
|
||||
from knack.config import CLIConfig
|
||||
|
||||
config_dir = os.getenv(
|
||||
"AZURE_CONFIG_DIR", os.path.expanduser(os.path.join("~", ".azure"))
|
||||
)
|
||||
return CLIConfig(config_dir=config_dir, config_env_var_prefix="AZURE")
|
||||
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class AzureFileSystem(ObjectFSWrapper):
|
||||
scheme = Schemes.AZURE
|
||||
@@ -79,19 +93,21 @@ class AzureFileSystem(ObjectFSWrapper):
|
||||
"azure-identity": "azure.identity",
|
||||
}
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str):
|
||||
bucket = infer_storage_options(path).get("host")
|
||||
if bucket:
|
||||
return path
|
||||
|
||||
url = config.get("url")
|
||||
self.path_info = self.PATH_CLS(url)
|
||||
self.bucket = self.path_info.bucket
|
||||
bucket = _az_config().get("storage", "container_name", None)
|
||||
return f"azure://{bucket}"
|
||||
|
||||
if not self.bucket:
|
||||
container = self._az_config.get("storage", "container_name", None)
|
||||
url = f"azure://{container}"
|
||||
|
||||
self.path_info = self.PATH_CLS(url)
|
||||
self.bucket = self.path_info.bucket
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
ops = infer_storage_options(urlpath)
|
||||
if "host" in ops:
|
||||
return {"bucket": ops["host"]}
|
||||
return {}
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
@@ -102,23 +118,36 @@ class AzureFileSystem(ObjectFSWrapper):
|
||||
login_info = {}
|
||||
login_info["connection_string"] = config.get(
|
||||
"connection_string",
|
||||
self._az_config.get("storage", "connection_string", None),
|
||||
_az_config().get("storage", "connection_string", None),
|
||||
)
|
||||
login_info["account_name"] = config.get(
|
||||
"account_name", self._az_config.get("storage", "account", None)
|
||||
"account_name", _az_config().get("storage", "account", None)
|
||||
)
|
||||
login_info["account_key"] = config.get(
|
||||
"account_key", self._az_config.get("storage", "key", None)
|
||||
"account_key", _az_config().get("storage", "key", None)
|
||||
)
|
||||
login_info["sas_token"] = config.get(
|
||||
"sas_token", self._az_config.get("storage", "sas_token", None)
|
||||
"sas_token", _az_config().get("storage", "sas_token", None)
|
||||
)
|
||||
login_info["tenant_id"] = config.get("tenant_id")
|
||||
login_info["client_id"] = config.get("client_id")
|
||||
login_info["client_secret"] = config.get("client_secret")
|
||||
|
||||
if not any(
|
||||
if not (login_info["account_name"] or login_info["connection_string"]):
|
||||
raise AzureAuthError(
|
||||
"Authentication to Azure Blob Storage requires either "
|
||||
"account_name or connection_string.\nLearn more about "
|
||||
"configuration settings at "
|
||||
+ format_link("https://man.dvc.org/remote/modify")
|
||||
)
|
||||
|
||||
any_secondary = any(
|
||||
value for key, value in login_info.items() if key != "account_name"
|
||||
)
|
||||
if (
|
||||
login_info["account_name"]
|
||||
and not any_secondary
|
||||
and not config.get("allow_anonymous_login", False)
|
||||
):
|
||||
login_info["credential"] = DefaultAzureCredential(
|
||||
exclude_interactive_browser_credential=False
|
||||
@@ -146,18 +175,6 @@ class AzureFileSystem(ObjectFSWrapper):
|
||||
self.login_method = login_method
|
||||
return login_info
|
||||
|
||||
@cached_property
|
||||
def _az_config(self):
|
||||
# NOTE: ideally we would've used get_default_cli().config from
|
||||
# azure.cli.core, but azure-cli-core has a lot of conflicts with other
|
||||
# dependencies. So instead we are just use knack directly
|
||||
from knack.config import CLIConfig
|
||||
|
||||
config_dir = os.getenv(
|
||||
"AZURE_CONFIG_DIR", os.path.expanduser(os.path.join("~", ".azure"))
|
||||
)
|
||||
return CLIConfig(config_dir=config_dir, config_env_var_prefix="AZURE")
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
@@ -167,10 +184,6 @@ class AzureFileSystem(ObjectFSWrapper):
|
||||
try:
|
||||
with _temp_event_loop():
|
||||
file_system = AzureBlobFileSystem(**self.fs_args)
|
||||
if self.bucket not in [
|
||||
container.rstrip("/") for container in file_system.ls("/")
|
||||
]:
|
||||
file_system.mkdir(self.bucket)
|
||||
except (ValueError, AzureError) as e:
|
||||
raise AzureAuthError(
|
||||
f"Authentication to Azure Blob Storage via {self.login_method}"
|
||||
|
||||
+23
-6
@@ -33,6 +33,8 @@ class RemoteMissingDepsError(DvcException):
|
||||
|
||||
|
||||
class BaseFileSystem:
|
||||
sep = "/"
|
||||
|
||||
scheme = "base"
|
||||
REQUIRES: ClassVar[Dict[str, str]] = {}
|
||||
PATH_CLS = URLInfo # type: Any
|
||||
@@ -55,11 +57,17 @@ class BaseFileSystem:
|
||||
def __init__(self, **kwargs):
|
||||
self._check_requires(**kwargs)
|
||||
|
||||
self.path_info = None
|
||||
|
||||
self.jobs = kwargs.get("jobs") or self._JOBS
|
||||
self.hash_jobs = kwargs.get("checksum_jobs") or self.HASH_JOBS
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str):
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath): # pylint:disable=unused-argument
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def get_missing_deps(cls):
|
||||
import importlib
|
||||
@@ -164,7 +172,7 @@ class BaseFileSystem:
|
||||
def ls(self, path_info, detail=False):
|
||||
raise RemoteActionNotImplemented("ls", self.scheme)
|
||||
|
||||
def find(self, path_info, detail=False):
|
||||
def find(self, path_info, detail=False, prefix=None):
|
||||
raise RemoteActionNotImplemented("find", self.scheme)
|
||||
|
||||
def is_empty(self, path_info):
|
||||
@@ -229,14 +237,23 @@ class BaseFileSystem:
|
||||
no_progress_bar=no_progress_bar,
|
||||
)
|
||||
|
||||
def upload_fobj(self, fobj, to_info, no_progress_bar=False, **pbar_args):
|
||||
def upload_fobj(
|
||||
self, fobj, to_info, no_progress_bar=False, size=None, **pbar_args
|
||||
):
|
||||
if not hasattr(self, "_upload_fobj"):
|
||||
raise RemoteActionNotImplemented("upload_fobj", self.scheme)
|
||||
|
||||
with Tqdm.wrapattr(
|
||||
fobj, "read", disable=no_progress_bar, bytes=True, **pbar_args
|
||||
fobj,
|
||||
"read",
|
||||
disable=no_progress_bar,
|
||||
bytes=True,
|
||||
total=size,
|
||||
**pbar_args,
|
||||
) as wrapped:
|
||||
self._upload_fobj(wrapped, to_info) # pylint: disable=no-member
|
||||
self._upload_fobj( # pylint: disable=no-member
|
||||
wrapped, to_info, size=size
|
||||
)
|
||||
|
||||
def download(
|
||||
self,
|
||||
|
||||
+3
-1
@@ -23,6 +23,8 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
repo: DVC repo.
|
||||
"""
|
||||
|
||||
sep = os.sep
|
||||
|
||||
scheme = "local"
|
||||
PARAM_CHECKSUM = "md5"
|
||||
|
||||
@@ -231,7 +233,7 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
|
||||
def info(self, path_info):
|
||||
meta = self.metadata(path_info)
|
||||
ret = {"type": "dir" if meta.isdir else "file"}
|
||||
ret = {"type": "directory" if meta.isdir else "file"}
|
||||
if meta.is_output and len(meta.outs) == 1 and meta.outs[0].hash_info:
|
||||
hash_info = meta.outs[0].hash_info
|
||||
ret["size"] = hash_info.size
|
||||
|
||||
@@ -11,6 +11,8 @@ from .base import BaseFileSystem
|
||||
|
||||
# pylint: disable=no-member
|
||||
class FSSpecWrapper(BaseFileSystem):
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.fs_args = {"skip_instance_cache": True}
|
||||
@@ -46,7 +48,7 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
yield entry
|
||||
|
||||
def _entry_hook(self, entry):
|
||||
"""Simple hook method to be overriden when wanted to process
|
||||
"""Simple hook method to be overridden when wanted to process
|
||||
entries within info() and ls(detail=True) calls"""
|
||||
return entry
|
||||
|
||||
@@ -94,7 +96,8 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
files = self.fs.ls(path, detail=detail)
|
||||
yield from self._strip_buckets(files, detail=detail)
|
||||
|
||||
def find(self, path_info, detail=False):
|
||||
# pylint: disable=unused-argument
|
||||
def find(self, path_info, detail=False, prefix=None):
|
||||
path = self._with_bucket(path_info)
|
||||
files = self.fs.find(path, detail=detail)
|
||||
if detail:
|
||||
@@ -103,7 +106,7 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
yield from self._strip_buckets(files, detail=detail)
|
||||
|
||||
def walk_files(self, path_info, **kwargs):
|
||||
for file in self.find(path_info):
|
||||
for file in self.find(path_info, **kwargs):
|
||||
yield path_info.replace(path=file)
|
||||
|
||||
def remove(self, path_info):
|
||||
@@ -115,20 +118,20 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
info["name"] = self._strip_bucket(info["name"])
|
||||
return info
|
||||
|
||||
def _upload_fobj(self, fobj, to_info):
|
||||
def _upload_fobj(self, fobj, to_info, size=None):
|
||||
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
|
||||
):
|
||||
total = os.path.getsize(from_file)
|
||||
size = os.path.getsize(from_file)
|
||||
with open(from_file, "rb") as fobj:
|
||||
self.upload_fobj(
|
||||
fobj,
|
||||
self._with_bucket(to_info),
|
||||
size=size,
|
||||
desc=name,
|
||||
total=total,
|
||||
no_progress_bar=no_progress_bar,
|
||||
)
|
||||
self.fs.invalidate_cache(self._with_bucket(to_info.parent))
|
||||
@@ -153,6 +156,8 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class ObjectFSWrapper(FSSpecWrapper):
|
||||
TRAVERSE_PREFIX_LEN = 3
|
||||
|
||||
def _isdir(self, path_info):
|
||||
# Directory in object storages are interpreted differently
|
||||
# among different fsspec providers, so this logic is a temporary
|
||||
@@ -167,9 +172,16 @@ class ObjectFSWrapper(FSSpecWrapper):
|
||||
and entry["name"].endswith("/")
|
||||
)
|
||||
|
||||
def find(self, path_info, detail=False):
|
||||
path = self._with_bucket(path_info)
|
||||
files = self.fs.find(path, detail=detail)
|
||||
def find(self, path_info, detail=False, prefix=None):
|
||||
if prefix:
|
||||
path = self._with_bucket(path_info.parent)
|
||||
files = self.fs.find(
|
||||
path, detail=detail, prefix=path_info.parts[-1]
|
||||
)
|
||||
else:
|
||||
path = self._with_bucket(path_info)
|
||||
files = self.fs.find(path, detail=detail)
|
||||
|
||||
if detail:
|
||||
files = files.values()
|
||||
|
||||
|
||||
+20
-8
@@ -1,3 +1,4 @@
|
||||
import errno
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
@@ -11,7 +12,7 @@ from urllib.parse import urlparse
|
||||
from funcy import cached_property, retry, wrap_prop, wrap_with
|
||||
from funcy.py3 import cat
|
||||
|
||||
from dvc.exceptions import DvcException, FileMissingError
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.path_info import CloudURLInfo
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.scheme import Schemes
|
||||
@@ -142,6 +143,10 @@ class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
return {"url": urlpath}
|
||||
|
||||
def _validate_config(self):
|
||||
# Validate Service Account configuration
|
||||
if (
|
||||
@@ -517,12 +522,14 @@ class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
return min(item_ids)
|
||||
|
||||
assert not create
|
||||
raise FileMissingError(path_info, hint)
|
||||
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 FileMissingError:
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
@@ -534,7 +541,7 @@ class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
query = f"({query}) and trashed=false"
|
||||
return self._gdrive_list(query)
|
||||
|
||||
def find(self, path_info, detail=False):
|
||||
def find(self, path_info, detail=False, prefix=None):
|
||||
root_path = path_info.path
|
||||
seen_paths = set()
|
||||
|
||||
@@ -612,24 +619,29 @@ class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
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")}
|
||||
return {"size": gdrive_file.get("fileSize"), "type": "file"}
|
||||
|
||||
def _upload_fobj(self, fobj, to_info):
|
||||
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
|
||||
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,
|
||||
total=os.path.getsize(from_file),
|
||||
)
|
||||
|
||||
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
|
||||
|
||||
+7
-1
@@ -1,5 +1,6 @@
|
||||
import errno
|
||||
import os
|
||||
import stat
|
||||
|
||||
from dvc.utils import is_exec, relpath
|
||||
|
||||
@@ -9,6 +10,8 @@ from .base import BaseFileSystem
|
||||
class GitFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
"""Proxies the repo file access methods to Git objects"""
|
||||
|
||||
sep = os.sep
|
||||
|
||||
scheme = "local"
|
||||
|
||||
def __init__(self, root_dir, trie):
|
||||
@@ -104,7 +107,10 @@ class GitFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), path_info
|
||||
)
|
||||
return {"size": st.st_size}
|
||||
return {
|
||||
"size": st.st_size,
|
||||
"type": "directory" if stat.S_ISDIR(st.st_mode) else "file",
|
||||
}
|
||||
|
||||
def stat(self, path):
|
||||
key = self._get_key(path)
|
||||
|
||||
+1
-6
@@ -16,12 +16,7 @@ class GSFileSystem(ObjectFSWrapper):
|
||||
REQUIRES = {"gcsfs": "gcsfs"}
|
||||
PARAM_CHECKSUM = "etag"
|
||||
DETAIL_FIELDS = frozenset(("etag", "size"))
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
url = config.get("url", "gs://")
|
||||
self.path_info = self.PATH_CLS(url)
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
login_info = {"consistency": None}
|
||||
|
||||
+25
-19
@@ -1,3 +1,4 @@
|
||||
import errno
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
@@ -6,7 +7,6 @@ import shutil
|
||||
import subprocess
|
||||
from collections import deque
|
||||
from contextlib import closing, contextmanager
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.progress import Tqdm
|
||||
@@ -68,24 +68,19 @@ class HDFSFileSystem(BaseFileSystem):
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
self.path_info = None
|
||||
url = config.get("url")
|
||||
if not url:
|
||||
return
|
||||
|
||||
parsed = urlparse(url)
|
||||
user = parsed.username or config.get("user")
|
||||
|
||||
self.path_info = self.PATH_CLS.from_parts(
|
||||
scheme=self.scheme,
|
||||
host=parsed.hostname,
|
||||
user=user,
|
||||
port=parsed.port,
|
||||
path=parsed.path,
|
||||
)
|
||||
self.host = config["host"]
|
||||
self.user = config.get("user")
|
||||
self.port = config.get("port")
|
||||
|
||||
self.kerb_ticket = config.get("kerb_ticket")
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
from fsspec.implementations.hdfs import PyArrowHDFS
|
||||
|
||||
# pylint:disable=protected-access
|
||||
return PyArrowHDFS._get_kwargs_from_urls(urlpath)
|
||||
|
||||
def hdfs(self, path_info):
|
||||
import pyarrow.fs
|
||||
|
||||
@@ -226,8 +221,19 @@ class HDFSFileSystem(BaseFileSystem):
|
||||
|
||||
def info(self, path_info):
|
||||
with self.hdfs(path_info) as hdfs:
|
||||
finfo = hdfs.get_file_info(path_info.path)
|
||||
return {"size": finfo.size}
|
||||
from pyarrow.fs import FileType
|
||||
|
||||
file_info = hdfs.get_file_info(path_info.path)
|
||||
if file_info.type is FileType.Directory:
|
||||
kind = "directory"
|
||||
elif file_info.type is FileType.File:
|
||||
kind = "file"
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), path_info
|
||||
)
|
||||
|
||||
return {"size": file_info.size, "type": kind}
|
||||
|
||||
def checksum(self, path_info):
|
||||
return HashInfo(
|
||||
@@ -236,7 +242,7 @@ class HDFSFileSystem(BaseFileSystem):
|
||||
size=self.getsize(path_info),
|
||||
)
|
||||
|
||||
def _upload_fobj(self, fobj, to_info):
|
||||
def _upload_fobj(self, fobj, to_info, **kwargs):
|
||||
with self.hdfs(to_info) as hdfs:
|
||||
with hdfs.open_output_stream(to_info.path) as fdest:
|
||||
shutil.copyfileobj(fobj, fdest)
|
||||
|
||||
+9
-13
@@ -2,6 +2,7 @@ import logging
|
||||
import os.path
|
||||
import threading
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from funcy import cached_property, memoize, wrap_prop, wrap_with
|
||||
|
||||
@@ -30,6 +31,7 @@ class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
PATH_CLS = HTTPURLInfo
|
||||
PARAM_CHECKSUM = "etag"
|
||||
CAN_TRAVERSE = False
|
||||
REQUIRES = {"requests": "requests"}
|
||||
|
||||
SESSION_RETRIES = 5
|
||||
SESSION_BACKOFF_FACTOR = 0.1
|
||||
@@ -39,13 +41,7 @@ class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
url = config.get("url")
|
||||
if url:
|
||||
self.path_info = self.PATH_CLS(url)
|
||||
self.user = config.get("user", None)
|
||||
self.host = self.path_info.host
|
||||
else:
|
||||
self.path_info = None
|
||||
self.user = config.get("user", None)
|
||||
|
||||
self.auth = config.get("auth", None)
|
||||
self.custom_auth_header = config.get("custom_auth_header", None)
|
||||
@@ -55,12 +51,12 @@ class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
self.ssl_verify = config.get("ssl_verify", True)
|
||||
self.method = config.get("method", "POST")
|
||||
|
||||
def _auth_method(self):
|
||||
def _auth_method(self, url):
|
||||
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
|
||||
|
||||
if self.auth:
|
||||
if self.ask_password and self.password is None:
|
||||
self.password = ask_password(self.host, self.user)
|
||||
self.password = ask_password(urlparse(url).hostname, self.user)
|
||||
if self.auth == "basic":
|
||||
return HTTPBasicAuth(self.user, self.password)
|
||||
if self.auth == "digest":
|
||||
@@ -103,7 +99,7 @@ class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
res = self._session.request(
|
||||
method,
|
||||
url,
|
||||
auth=self._auth_method(),
|
||||
auth=self._auth_method(url),
|
||||
headers=self.headers,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -150,9 +146,9 @@ class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
resp = self._head(path_info.url)
|
||||
etag = resp.headers.get("ETag") or resp.headers.get("Content-MD5")
|
||||
size = self._content_length(resp)
|
||||
return {"etag": etag, "size": size}
|
||||
return {"etag": etag, "size": size, "type": "file"}
|
||||
|
||||
def _upload_fobj(self, fobj, to_info):
|
||||
def _upload_fobj(self, fobj, to_info, **kwargs):
|
||||
def chunks(fobj):
|
||||
while True:
|
||||
chunk = fobj.read(self.CHUNK_SIZE)
|
||||
@@ -189,9 +185,9 @@ class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
self.upload_fobj(
|
||||
fobj,
|
||||
to_info,
|
||||
size=None if no_progress_bar else os.path.getsize(from_file),
|
||||
no_progress_bar=no_progress_bar,
|
||||
desc=name or to_info.url,
|
||||
total=None if no_progress_bar else os.path.getsize(from_file),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
+4
-4
@@ -14,6 +14,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalFileSystem(BaseFileSystem):
|
||||
sep = os.sep
|
||||
|
||||
scheme = Schemes.LOCAL
|
||||
PATH_CLS = PathInfo
|
||||
PARAM_CHECKSUM = "md5"
|
||||
@@ -25,8 +27,6 @@ class LocalFileSystem(BaseFileSystem):
|
||||
|
||||
super().__init__(**config)
|
||||
self.fs = LocalFS()
|
||||
url = config.get("url")
|
||||
self.path_info = self.PATH_CLS(url) if url else None
|
||||
|
||||
@staticmethod
|
||||
def open(path_info, mode="r", encoding=None, **kwargs):
|
||||
@@ -105,7 +105,7 @@ class LocalFileSystem(BaseFileSystem):
|
||||
self.remove(tmp_info)
|
||||
raise
|
||||
|
||||
def _upload_fobj(self, fobj, to_info):
|
||||
def _upload_fobj(self, fobj, to_info, **kwargs):
|
||||
self.makedirs(to_info.parent)
|
||||
tmp_info = to_info.parent / tmp_fname("")
|
||||
try:
|
||||
@@ -157,7 +157,7 @@ class LocalFileSystem(BaseFileSystem):
|
||||
st = os.stat(path_info)
|
||||
return {
|
||||
"size": st.st_size,
|
||||
"type": "dir" if stat.S_ISDIR(st.st_mode) else "file",
|
||||
"type": "directory" if stat.S_ISDIR(st.st_mode) else "file",
|
||||
}
|
||||
|
||||
def _upload(
|
||||
|
||||
+16
-26
@@ -42,10 +42,6 @@ class OSSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
url = config.get("url")
|
||||
self.path_info = self.PATH_CLS(url) if url else None
|
||||
|
||||
self.bucket = self.path_info.bucket
|
||||
self.endpoint = config.get("oss_endpoint") or os.getenv("OSS_ENDPOINT")
|
||||
|
||||
self.key_id = (
|
||||
@@ -68,25 +64,17 @@ class OSSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
logger.debug(f"key id: {self.key_id}")
|
||||
logger.debug(f"key secret: {self.key_secret}")
|
||||
|
||||
auth = oss2.Auth(self.key_id, self.key_secret)
|
||||
bucket = oss2.Bucket(auth, self.endpoint, self.bucket)
|
||||
return oss2.Auth(self.key_id, self.key_secret)
|
||||
|
||||
# Ensure bucket exists
|
||||
try:
|
||||
bucket.get_bucket_info()
|
||||
except oss2.exceptions.NoSuchBucket:
|
||||
bucket.create_bucket(
|
||||
oss2.BUCKET_ACL_PUBLIC_READ,
|
||||
oss2.models.BucketCreateConfig(
|
||||
oss2.BUCKET_STORAGE_CLASS_STANDARD
|
||||
),
|
||||
)
|
||||
return bucket
|
||||
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):
|
||||
assert path_info.bucket == self.bucket
|
||||
|
||||
return self.oss_service.sign_url("GET", path_info.path, expires)
|
||||
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)
|
||||
@@ -96,7 +84,7 @@ class OSSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
import oss2
|
||||
|
||||
for blob in oss2.ObjectIterator(
|
||||
self.oss_service, prefix=path_info.path
|
||||
self._get_bucket(path_info.bucket), prefix=path_info.path
|
||||
):
|
||||
yield blob.key
|
||||
|
||||
@@ -114,16 +102,17 @@ class OSSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
raise NotImplementedError
|
||||
|
||||
logger.debug(f"Removing oss://{path_info}")
|
||||
self.oss_service.delete_object(path_info.path)
|
||||
self._get_bucket(path_info.bucket).delete_object(path_info.path)
|
||||
|
||||
def _upload_fobj(self, fobj, to_info):
|
||||
self.oss_service.put_object(to_info.path, fobj)
|
||||
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:
|
||||
self.oss_service.put_object_from_file(
|
||||
bucket = self._get_bucket(to_info.bucket)
|
||||
bucket.put_object_from_file(
|
||||
to_info.path, from_file, progress_callback=pbar.update_to
|
||||
)
|
||||
|
||||
@@ -133,8 +122,9 @@ class OSSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
with Tqdm(desc=name, disable=no_progress_bar, bytes=True) as pbar:
|
||||
import oss2
|
||||
|
||||
bucket = self._get_bucket(from_info.bucket)
|
||||
oss2.resumable_download(
|
||||
self.oss_service,
|
||||
bucket,
|
||||
from_info.path,
|
||||
to_file,
|
||||
progress_callback=pbar.update_to,
|
||||
|
||||
@@ -31,6 +31,8 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
kwargs: Additional keyword arguments passed to the `DvcFileSystem()`.
|
||||
"""
|
||||
|
||||
sep = os.sep
|
||||
|
||||
scheme = "local"
|
||||
PARAM_CHECKSUM = "md5"
|
||||
|
||||
@@ -64,6 +66,12 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
if hasattr(repo, "dvc_dir"):
|
||||
self._dvcfss[repo.root_dir] = DvcFileSystem(repo=repo)
|
||||
|
||||
@property
|
||||
def repo_url(self):
|
||||
if self._main_repo is None:
|
||||
return None
|
||||
return self._main_repo.url
|
||||
|
||||
def _get_repo(self, path: str) -> Optional["Repo"]:
|
||||
"""Returns repo that the path falls in, using prefix.
|
||||
|
||||
|
||||
+2
-7
@@ -29,12 +29,6 @@ class BaseS3FileSystem(ObjectFSWrapper):
|
||||
"grant_write_acp": "GrantWriteACP",
|
||||
}
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
url = config.get("url", "s3://")
|
||||
self.path_info = self.PATH_CLS(url)
|
||||
|
||||
_TRANSFER_CONFIG_ALIASES = {
|
||||
"max_queue_size": "max_io_queue",
|
||||
"max_concurrent_requests": "max_concurrency",
|
||||
@@ -103,7 +97,7 @@ class BaseS3FileSystem(ObjectFSWrapper):
|
||||
client = login_info["client_kwargs"]
|
||||
client["region_name"] = config.get("region")
|
||||
client["endpoint_url"] = config.get("endpointurl")
|
||||
client["verify"] = config.get("ssl_verify", True)
|
||||
client["verify"] = config.get("ssl_verify")
|
||||
|
||||
# encryptions
|
||||
additional = login_info["s3_additional_kwargs"]
|
||||
@@ -194,6 +188,7 @@ class S3FileSystem(BaseS3FileSystem): # pylint:disable=abstract-method
|
||||
"s3",
|
||||
endpoint_url=client_kwargs.get("endpoint_url"),
|
||||
use_ssl=login_info["use_ssl"],
|
||||
verify=client_kwargs.get("verify"),
|
||||
)
|
||||
|
||||
def _get_obj(self, path_info):
|
||||
|
||||
+47
-57
@@ -5,7 +5,6 @@ import os
|
||||
import shutil
|
||||
import threading
|
||||
from contextlib import closing, contextmanager
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from funcy import first, memoize, silent, wrap_with
|
||||
|
||||
@@ -48,34 +47,19 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
url = config.get("url")
|
||||
if url:
|
||||
parsed = urlparse(url)
|
||||
user_ssh_config = self._load_user_ssh_config(parsed.hostname)
|
||||
user_ssh_config = self._load_user_ssh_config(config["host"])
|
||||
|
||||
host = user_ssh_config.get("hostname", parsed.hostname)
|
||||
user = (
|
||||
config.get("user")
|
||||
or parsed.username
|
||||
or user_ssh_config.get("user")
|
||||
or getpass.getuser()
|
||||
)
|
||||
port = (
|
||||
config.get("port")
|
||||
or parsed.port
|
||||
or self._try_get_ssh_config_port(user_ssh_config)
|
||||
or self.DEFAULT_PORT
|
||||
)
|
||||
self.path_info = self.PATH_CLS.from_parts(
|
||||
scheme=self.scheme,
|
||||
host=host,
|
||||
user=user,
|
||||
port=port,
|
||||
path=parsed.path,
|
||||
)
|
||||
else:
|
||||
self.path_info = None
|
||||
user_ssh_config = {}
|
||||
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"
|
||||
@@ -93,6 +77,16 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
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"))
|
||||
@@ -120,25 +114,21 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
def _try_get_ssh_config_keyfile(user_ssh_config):
|
||||
return first(user_ssh_config.get("identityfile") or ())
|
||||
|
||||
def ensure_credentials(self, path_info=None):
|
||||
if path_info is None:
|
||||
path_info = self.path_info
|
||||
|
||||
def ensure_credentials(self):
|
||||
# NOTE: we use the same password regardless of the server :(
|
||||
if self.ask_password and self.password is None:
|
||||
host, user, port = path_info.host, path_info.user, path_info.port
|
||||
self.password = ask_password(host, user, port)
|
||||
self.password = ask_password(self.host, self.user, self.port)
|
||||
|
||||
def ssh(self, path_info):
|
||||
self.ensure_credentials(path_info)
|
||||
def ssh(self):
|
||||
self.ensure_credentials()
|
||||
|
||||
from .connection import SSHConnection
|
||||
|
||||
return get_connection(
|
||||
SSHConnection,
|
||||
path_info.host,
|
||||
username=path_info.user,
|
||||
port=path_info.port,
|
||||
self.host,
|
||||
username=self.user,
|
||||
port=self.port,
|
||||
key_filename=self.keyfile,
|
||||
timeout=self.timeout,
|
||||
password=self.password,
|
||||
@@ -151,7 +141,7 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
def open(self, path_info, mode="r", encoding=None, **kwargs):
|
||||
assert mode in {"r", "rt", "rb", "wb"}
|
||||
|
||||
with self.ssh(path_info) as ssh, closing(
|
||||
with self.ssh() as ssh, closing(
|
||||
ssh.sftp.open(path_info.path, mode)
|
||||
) as fd:
|
||||
if "b" in mode:
|
||||
@@ -160,19 +150,19 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
yield io.TextIOWrapper(fd, encoding=encoding)
|
||||
|
||||
def exists(self, path_info) -> bool:
|
||||
with self.ssh(path_info) as ssh:
|
||||
with self.ssh() as ssh:
|
||||
return ssh.exists(path_info.path)
|
||||
|
||||
def isdir(self, path_info):
|
||||
with self.ssh(path_info) as ssh:
|
||||
with self.ssh() as ssh:
|
||||
return ssh.isdir(path_info.path)
|
||||
|
||||
def isfile(self, path_info):
|
||||
with self.ssh(path_info) as ssh:
|
||||
with self.ssh() as ssh:
|
||||
return ssh.isfile(path_info.path)
|
||||
|
||||
def walk_files(self, path_info, **kwargs):
|
||||
with self.ssh(path_info) as ssh:
|
||||
with self.ssh() as ssh:
|
||||
for fname in ssh.walk_files(path_info.path):
|
||||
yield path_info.replace(path=fname)
|
||||
|
||||
@@ -180,32 +170,32 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
if path_info.scheme != self.scheme:
|
||||
raise NotImplementedError
|
||||
|
||||
with self.ssh(path_info) as ssh:
|
||||
with self.ssh() as ssh:
|
||||
ssh.remove(path_info.path)
|
||||
|
||||
def makedirs(self, path_info):
|
||||
with self.ssh(path_info) as ssh:
|
||||
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(from_info) as ssh:
|
||||
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(from_info) as ssh:
|
||||
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(from_info) as ssh:
|
||||
with self.ssh() as ssh:
|
||||
ssh.symlink(from_info.path, to_info.path)
|
||||
|
||||
def hardlink(self, from_info, to_info):
|
||||
@@ -215,7 +205,7 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
# See dvc/remote/local/__init__.py - hardlink()
|
||||
if self.getsize(from_info) == 0:
|
||||
|
||||
with self.ssh(to_info) as ssh:
|
||||
with self.ssh() as ssh:
|
||||
ssh.sftp.open(to_info.path, "w").close()
|
||||
|
||||
logger.debug(
|
||||
@@ -225,18 +215,18 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
)
|
||||
return
|
||||
|
||||
with self.ssh(from_info) as ssh:
|
||||
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(from_info) as ssh:
|
||||
with self.ssh() as ssh:
|
||||
ssh.reflink(from_info.path, to_info.path)
|
||||
|
||||
def md5(self, path_info):
|
||||
with self.ssh(path_info) as ssh:
|
||||
with self.ssh() as ssh:
|
||||
return HashInfo(
|
||||
"md5",
|
||||
ssh.md5(path_info.path),
|
||||
@@ -244,16 +234,16 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
)
|
||||
|
||||
def info(self, path_info):
|
||||
with self.ssh(path_info) as ssh:
|
||||
with self.ssh() as ssh:
|
||||
return ssh.info(path_info.path)
|
||||
|
||||
def _upload_fobj(self, fobj, to_info):
|
||||
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(from_info) as ssh:
|
||||
with self.ssh() as ssh:
|
||||
ssh.download(
|
||||
from_info.path,
|
||||
to_file,
|
||||
@@ -264,7 +254,7 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
def _upload(
|
||||
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
|
||||
):
|
||||
with self.ssh(to_info) as ssh:
|
||||
with self.ssh() as ssh:
|
||||
ssh.upload(
|
||||
from_file,
|
||||
to_info.path,
|
||||
|
||||
@@ -82,7 +82,7 @@ class SSHConnection:
|
||||
st = self.sftp.stat(path)
|
||||
return {
|
||||
"size": st.st_size,
|
||||
"type": "dir" if stat.S_ISDIR(st.st_mode) else "file",
|
||||
"type": "directory" if stat.S_ISDIR(st.st_mode) else "file",
|
||||
}
|
||||
|
||||
def getsize(self, path):
|
||||
|
||||
+64
-233
@@ -1,270 +1,101 @@
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from collections import deque
|
||||
from functools import lru_cache
|
||||
|
||||
from funcy import cached_property, nullcontext, wrap_prop
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.config import ConfigError
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.path_info import HTTPURLInfo, WebDAVURLInfo
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.path_info import WebDAVURLInfo
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .base import BaseFileSystem
|
||||
from .fsspec_wrapper import FSSpecWrapper
|
||||
from .http import ask_password
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebDAVConnectionError(DvcException):
|
||||
def __init__(self, host):
|
||||
super().__init__(f"Unable to connect to WebDAV {host}.")
|
||||
|
||||
|
||||
class WebDAVFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
# Use webdav scheme
|
||||
class WebDAVFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
|
||||
scheme = Schemes.WEBDAV
|
||||
|
||||
# URLInfo for Webdav ~ replaces webdav -> http
|
||||
PATH_CLS = WebDAVURLInfo
|
||||
|
||||
# Traversable as walk_files is implemented
|
||||
CAN_TRAVERSE = True
|
||||
|
||||
# Length of walk_files prefix
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
|
||||
# Implementation based on webdav3.client
|
||||
REQUIRES = {"webdavclient3": "webdav3.client"}
|
||||
|
||||
# Chunk size for buffered upload/download with progress bar
|
||||
CHUNK_SIZE = 2 ** 16
|
||||
|
||||
REQUIRES = {"webdav4": "webdav4"}
|
||||
PARAM_CHECKSUM = "etag"
|
||||
DETAIL_FIELDS = frozenset(("etag", "size"))
|
||||
|
||||
# Constructor
|
||||
def __init__(self, **config):
|
||||
# Call BaseFileSystem constructor
|
||||
super().__init__(**config)
|
||||
|
||||
# Get username from configuration
|
||||
self.user = config.get("user", None)
|
||||
cert_path = config.get("cert_path", None)
|
||||
key_path = config.get("key_path", None)
|
||||
self.prefix = config.get("prefix", "")
|
||||
self.fs_args.update(
|
||||
{
|
||||
"base_url": config["url"],
|
||||
"cert": cert_path if not key_path else (cert_path, key_path),
|
||||
"verify": config.get("ssl_verify", True),
|
||||
"timeout": config.get("timeout", 30),
|
||||
}
|
||||
)
|
||||
|
||||
# Get password from configuration (might be None ~ not set)
|
||||
self.password = config.get("password", None)
|
||||
|
||||
# Whether to ask for password if it is not set
|
||||
self.ask_password = config.get("ask_password", False)
|
||||
|
||||
# Use token for webdav auth
|
||||
self.token = config.get("token", None)
|
||||
|
||||
# Path to certificate
|
||||
self.cert_path = config.get("cert_path", None)
|
||||
|
||||
# Path to private key
|
||||
self.key_path = config.get("key_path", None)
|
||||
|
||||
# Connection timeout
|
||||
self.timeout = config.get("timeout", 30)
|
||||
|
||||
# Get URL from configuration
|
||||
self.url = config.get("url", None)
|
||||
|
||||
# If URL in config parse path_info
|
||||
if self.url:
|
||||
self.path_info = self.PATH_CLS(self.url)
|
||||
|
||||
# If username not specified try to use from URL
|
||||
if self.user is None and self.path_info.user is not None:
|
||||
self.user = self.path_info.user
|
||||
|
||||
# Construct hostname from path_info by stripping path
|
||||
http_info = HTTPURLInfo(self.path_info.url)
|
||||
self.hostname = http_info.replace(path="").url
|
||||
|
||||
# Webdav client
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def _client(self):
|
||||
from webdav3.client import Client
|
||||
|
||||
# Set password or ask for it
|
||||
if self.ask_password and self.password is None and self.token is None:
|
||||
self.password = ask_password(self.hostname, self.user)
|
||||
|
||||
# Setup webdav client options dictionary
|
||||
options = {
|
||||
"webdav_hostname": self.hostname,
|
||||
"webdav_login": self.user,
|
||||
"webdav_password": self.password,
|
||||
"webdav_token": self.token,
|
||||
"webdav_cert_path": self.cert_path,
|
||||
"webdav_key_path": self.key_path,
|
||||
"webdav_timeout": self.timeout,
|
||||
"webdav_chunk_size": self.CHUNK_SIZE,
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
path_info = WebDAVURLInfo(urlpath)
|
||||
return {
|
||||
"prefix": path_info.path.rstrip("/"),
|
||||
"host": path_info.replace(path="").url,
|
||||
"url": path_info.url.rstrip("/"),
|
||||
"user": path_info.user,
|
||||
}
|
||||
|
||||
client = Client(options)
|
||||
def _prepare_credentials(self, **config):
|
||||
user = config.get("user", None)
|
||||
password = config.get("password", None)
|
||||
|
||||
# Check whether client options are valid
|
||||
if not client.valid():
|
||||
raise ConfigError(
|
||||
f"Configuration for WebDAV {self.hostname} is invalid."
|
||||
)
|
||||
headers = {}
|
||||
token = config.get("token")
|
||||
auth = None
|
||||
if token:
|
||||
headers.update({"Authorization": f"Bearer {token}"})
|
||||
elif user:
|
||||
if not password and config.get("ask_password"):
|
||||
password = ask_password(config["host"], user)
|
||||
auth = (user, password)
|
||||
|
||||
# Check whether connection is valid (root should always exist)
|
||||
if not client.check(self.path_info.path):
|
||||
raise WebDAVConnectionError(self.hostname)
|
||||
return {"headers": headers, "auth": auth}
|
||||
|
||||
return client
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from webdav4.fsspec import WebdavFileSystem
|
||||
|
||||
def open(self, path_info, mode="r", encoding=None, **kwargs):
|
||||
from webdav3.exceptions import RemoteResourceNotFound
|
||||
return WebdavFileSystem(**self.fs_args)
|
||||
|
||||
assert mode in {"r", "rt", "rb"}
|
||||
def _upload_fobj(self, fobj, to_info, size: int = None):
|
||||
rpath = self.translate_path_info(to_info)
|
||||
self.makedirs(os.path.dirname(rpath))
|
||||
# using upload_fileobj to directly upload fileobj rather than buffering
|
||||
# and using overwrite=True to avoid check for an extra exists call,
|
||||
# as caller should ensure that the file does not exist beforehand.
|
||||
return self.fs.client.upload_fileobj(
|
||||
fobj, rpath, overwrite=True, size=size
|
||||
)
|
||||
|
||||
fobj = io.BytesIO()
|
||||
|
||||
try:
|
||||
self._client.download_from(buff=fobj, remote_path=path_info.path)
|
||||
except RemoteResourceNotFound as exc:
|
||||
raise FileNotFoundError from exc
|
||||
|
||||
fobj.seek(0)
|
||||
|
||||
if "mode" == "rb":
|
||||
return fobj
|
||||
|
||||
return io.TextIOWrapper(fobj, encoding=encoding)
|
||||
|
||||
# Checks whether file/directory exists at remote
|
||||
def exists(self, path_info) -> bool:
|
||||
# Use webdav check to test for file existence
|
||||
return self._client.check(path_info.path)
|
||||
|
||||
# Checks whether path points to directory
|
||||
def isdir(self, path_info):
|
||||
# Use webdav is_dir to test whether path points to a directory
|
||||
return self._client.is_dir(path_info.path)
|
||||
|
||||
def walk_files(self, path_info, **kwargs):
|
||||
if not self.exists(path_info):
|
||||
return
|
||||
|
||||
for file in self.find(path_info):
|
||||
yield path_info.replace(path=file)
|
||||
|
||||
def ls(self, path_info, detail=False):
|
||||
for entry in self._client.list(path_info.path):
|
||||
path = entry["path"]
|
||||
if detail:
|
||||
if entry["isdir"]:
|
||||
yield {"type": "directory", "name": path}
|
||||
else:
|
||||
yield {
|
||||
"type": "file",
|
||||
"name": path,
|
||||
"size": entry["size"],
|
||||
"etag": entry["etag"],
|
||||
}
|
||||
else:
|
||||
yield path
|
||||
|
||||
def find(self, path_info, detail=False):
|
||||
dirs = deque([path_info.path])
|
||||
|
||||
while dirs:
|
||||
for entry in self._client.list(dirs.pop(), get_info=True):
|
||||
path = entry["path"]
|
||||
if entry["isdir"]:
|
||||
dirs.append(path)
|
||||
continue
|
||||
|
||||
if detail:
|
||||
yield {
|
||||
"type": "file",
|
||||
"name": path,
|
||||
"size": entry["size"],
|
||||
"etag": entry["etag"],
|
||||
}
|
||||
else:
|
||||
yield path
|
||||
|
||||
# Removes file/directory
|
||||
def remove(self, path_info):
|
||||
# Use webdav client clean (DELETE) method to remove file/directory
|
||||
self._client.clean(path_info.path)
|
||||
|
||||
# Creates directories
|
||||
def makedirs(self, path_info):
|
||||
# Terminate recursion
|
||||
if path_info.path == self.path_info.path or self.exists(path_info):
|
||||
return
|
||||
path = self.translate_path_info(path_info)
|
||||
return self.fs.makedirs(path, exist_ok=True)
|
||||
|
||||
# Recursively descent to root
|
||||
self.makedirs(path_info.parent)
|
||||
@lru_cache(512)
|
||||
def translate_path_info(self, path):
|
||||
if isinstance(path, self.PATH_CLS):
|
||||
return path.path[len(self.prefix) :].lstrip("/")
|
||||
return path
|
||||
|
||||
# Construct directory at current recursion depth
|
||||
self._client.mkdir(path_info.path)
|
||||
_with_bucket = translate_path_info
|
||||
|
||||
# Moves file/directory at remote
|
||||
def move(self, from_info, to_info):
|
||||
# Webdav client move
|
||||
self._client.move(from_info.path, to_info.path)
|
||||
def _strip_bucket(self, entry):
|
||||
return entry
|
||||
|
||||
def _upload_fobj(self, fobj, to_info):
|
||||
# In contrast to other upload_fobj implementations, this one does not
|
||||
# exactly do a chunked-upload but rather put everything in one request.
|
||||
self.makedirs(to_info.parent)
|
||||
self._client.upload_to(buff=fobj, remote_path=to_info.path)
|
||||
|
||||
# Downloads file from remote to file
|
||||
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
|
||||
# Progress from HTTPFileSystem
|
||||
with open(to_file, "wb") as fd:
|
||||
with Tqdm.wrapattr(
|
||||
fd,
|
||||
"write",
|
||||
total=None if no_progress_bar else self.getsize(from_info),
|
||||
leave=False,
|
||||
desc=from_info.url if name is None else name,
|
||||
disable=no_progress_bar,
|
||||
) as fd_wrapped:
|
||||
# Download from WebDAV via buffer
|
||||
self._client.download_from(
|
||||
buff=fd_wrapped, remote_path=from_info.path
|
||||
)
|
||||
|
||||
# Uploads file to remote
|
||||
def _upload(
|
||||
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
|
||||
):
|
||||
# First try to create parent directories
|
||||
self.makedirs(to_info.parent)
|
||||
|
||||
file_size = os.path.getsize(from_file)
|
||||
with open(from_file, "rb") as fd:
|
||||
progress_context = (
|
||||
nullcontext(fd)
|
||||
if file_size == 0
|
||||
else Tqdm.wrapattr(
|
||||
fd,
|
||||
"read",
|
||||
total=None if no_progress_bar else file_size,
|
||||
leave=False,
|
||||
desc=to_info.url if name is None else name,
|
||||
disable=no_progress_bar,
|
||||
)
|
||||
)
|
||||
with progress_context as fd_wrapped:
|
||||
self._client.upload_to(
|
||||
buff=fd_wrapped, remote_path=to_info.path
|
||||
)
|
||||
|
||||
def info(self, path_info):
|
||||
info = self._client.info(path_info.path)
|
||||
return {"size": int(info["size"]), "etag": info["etag"]}
|
||||
class WebDAVSFileSystem(WebDAVFileSystem): # pylint:disable=abstract-method
|
||||
scheme = Schemes.WEBDAVS
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .webdav import WebDAVFileSystem
|
||||
|
||||
|
||||
class WebDAVSFileSystem(WebDAVFileSystem): # pylint:disable=abstract-method
|
||||
scheme = Schemes.WEBDAVS
|
||||
+15
-15
@@ -4,7 +4,6 @@ import posixpath
|
||||
import shutil
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
@@ -40,23 +39,24 @@ class WebHDFSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
self.path_info = None
|
||||
url = config.get("url")
|
||||
if not url:
|
||||
return
|
||||
|
||||
self.path_info = self.PATH_CLS(url)
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
self.host = parsed.hostname
|
||||
self.user = parsed.username or config.get("user")
|
||||
self.port = parsed.port
|
||||
self.host = config["host"]
|
||||
self.user = config.get("user")
|
||||
self.port = config.get("port")
|
||||
|
||||
self.hdfscli_config = config.get("hdfscli_config")
|
||||
self.token = config.get("webhdfs_token")
|
||||
self.alias = config.get("webhdfs_alias")
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
from fsspec.implementations.webhdfs import WebHDFS
|
||||
|
||||
return (
|
||||
WebHDFS._get_kwargs_from_urls( # pylint:disable=protected-access
|
||||
urlpath
|
||||
)
|
||||
)
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def hdfs_client(self):
|
||||
@@ -123,7 +123,7 @@ class WebHDFSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
|
||||
def info(self, path_info):
|
||||
st = self.hdfs_client.status(path_info.path)
|
||||
return {"size": st["length"]}
|
||||
return {"size": st["length"], "type": "file"}
|
||||
|
||||
def checksum(self, path_info):
|
||||
return HashInfo(
|
||||
@@ -141,7 +141,7 @@ class WebHDFSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
self.hdfs_client.makedirs(to_info.parent.path)
|
||||
self.hdfs_client.rename(from_info.path, to_info.path)
|
||||
|
||||
def _upload_fobj(self, fobj, to_info):
|
||||
def _upload_fobj(self, fobj, to_info, **kwargs):
|
||||
with self.hdfs_client.write(to_info.path) as fdest:
|
||||
shutil.copyfileobj(fobj, fdest)
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ class HashInfo:
|
||||
def __str__(self):
|
||||
return f"{self.name}: {self.value}"
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.name, self.value))
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
_d = d.copy() if d else {}
|
||||
|
||||
+18
-9
@@ -20,6 +20,11 @@ if PKG is None:
|
||||
else:
|
||||
package = f"({PKG})"
|
||||
|
||||
try:
|
||||
import importlib.metadata as importlib_metadata
|
||||
except ImportError: # < 3.8
|
||||
import importlib_metadata # type: ignore[no-redef]
|
||||
|
||||
|
||||
def get_dvc_info():
|
||||
info = [
|
||||
@@ -27,7 +32,7 @@ def get_dvc_info():
|
||||
"---------------------------------",
|
||||
f"Platform: Python {platform.python_version()} on "
|
||||
f"{platform.platform()}",
|
||||
f"Supports: {_get_supported_remotes()}",
|
||||
f"Supports:{_get_supported_remotes()}",
|
||||
]
|
||||
|
||||
try:
|
||||
@@ -113,19 +118,23 @@ def _get_linktype_support_info(repo):
|
||||
|
||||
|
||||
def _get_supported_remotes():
|
||||
|
||||
supported_remotes = []
|
||||
for scheme, fs_cls in FS_MAP.items():
|
||||
if not fs_cls.get_missing_deps():
|
||||
supported_remotes.append(scheme)
|
||||
dependencies = []
|
||||
for requirement in fs_cls.REQUIRES:
|
||||
dependencies.append(
|
||||
f"{requirement} = "
|
||||
f"{importlib_metadata.version(requirement)}"
|
||||
)
|
||||
|
||||
if len(supported_remotes) == len(FS_MAP):
|
||||
return "All remotes"
|
||||
remote_info = scheme
|
||||
if dependencies:
|
||||
remote_info += " (" + ", ".join(dependencies) + ")"
|
||||
supported_remotes.append(remote_info)
|
||||
|
||||
if len(supported_remotes) == 1:
|
||||
return supported_remotes
|
||||
|
||||
return ", ".join(supported_remotes)
|
||||
assert len(supported_remotes) >= 1
|
||||
return "\n\t" + ",\n\t".join(supported_remotes)
|
||||
|
||||
|
||||
def get_fs_type(path):
|
||||
|
||||
+12
-2
@@ -1,14 +1,24 @@
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
from .tree import Tree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .db.base import ObjectDB
|
||||
from .file import HashFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def save(odb, obj, jobs=None, **kwargs):
|
||||
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(
|
||||
@@ -18,7 +28,7 @@ def save(odb, obj, jobs=None, **kwargs):
|
||||
entry.path_info,
|
||||
entry.fs,
|
||||
entry.hash_info,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
)
|
||||
for _, entry in obj
|
||||
),
|
||||
|
||||
+7
-105
@@ -1,24 +1,22 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
|
||||
def get_odb(fs, **config):
|
||||
def get_odb(fs, path_info, **config):
|
||||
from .base import ObjectDB
|
||||
from .gdrive import GDriveObjectDB
|
||||
from .local import LocalObjectDB
|
||||
from .ssh import SSHObjectDB
|
||||
|
||||
if fs.scheme == Schemes.LOCAL:
|
||||
return LocalObjectDB(fs, **config)
|
||||
return LocalObjectDB(fs, path_info, **config)
|
||||
|
||||
if fs.scheme == Schemes.SSH:
|
||||
return SSHObjectDB(fs, **config)
|
||||
return SSHObjectDB(fs, path_info, **config)
|
||||
|
||||
if fs.scheme == Schemes.GDRIVE:
|
||||
return GDriveObjectDB(fs, **config)
|
||||
return GDriveObjectDB(fs, path_info, **config)
|
||||
|
||||
return ObjectDB(fs, **config)
|
||||
return ObjectDB(fs, path_info, **config)
|
||||
|
||||
|
||||
def _get_odb(repo, settings):
|
||||
@@ -27,8 +25,8 @@ def _get_odb(repo, settings):
|
||||
if not settings:
|
||||
return None
|
||||
|
||||
cls, config = get_cloud_fs(repo, **settings)
|
||||
return get_odb(cls(**config), state=repo.state, **config)
|
||||
cls, config, path_info = get_cloud_fs(repo, **settings)
|
||||
return get_odb(cls(**config), path_info, state=repo.state, **config)
|
||||
|
||||
|
||||
class ODBManager:
|
||||
@@ -80,99 +78,3 @@ class ODBManager:
|
||||
def by_scheme(self):
|
||||
self._init_odb(self.CLOUD_SCHEMES)
|
||||
yield from self._odb.items()
|
||||
|
||||
|
||||
class NamedCacheItem:
|
||||
def __init__(self):
|
||||
self.names = set()
|
||||
self.children = defaultdict(NamedCacheItem)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.names == other.names and self.children == other.children
|
||||
|
||||
def child_keys(self):
|
||||
for key, child in self.children.items():
|
||||
yield key
|
||||
yield from child.child_keys()
|
||||
|
||||
def child_names(self):
|
||||
for key, child in self.children.items():
|
||||
yield key, child.names
|
||||
yield from child.child_names()
|
||||
|
||||
def add(self, checksum, item):
|
||||
self.children[checksum].update(item)
|
||||
|
||||
def update(self, item, suffix=""):
|
||||
if suffix:
|
||||
self.names.update(n + suffix for n in item.names)
|
||||
else:
|
||||
self.names.update(item.names)
|
||||
for checksum, child_item in item.children.items():
|
||||
self.children[checksum].update(child_item)
|
||||
|
||||
|
||||
class NamedCache:
|
||||
# pylint: disable=protected-access
|
||||
def __init__(self):
|
||||
self._items = defaultdict(lambda: defaultdict(NamedCacheItem))
|
||||
self.external = defaultdict(set)
|
||||
|
||||
@classmethod
|
||||
def make(cls, scheme, checksum, name):
|
||||
cache = cls()
|
||||
cache.add(scheme, checksum, name)
|
||||
return cache
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._items[key]
|
||||
|
||||
def add(self, scheme, checksum, name):
|
||||
"""Add a mapped name for the specified checksum."""
|
||||
self._items[scheme][checksum].names.add(name)
|
||||
|
||||
def add_child_cache(self, checksum, cache, suffix=""):
|
||||
"""Add/update child cache for the specified checksum."""
|
||||
for scheme, src in cache._items.items():
|
||||
dst = self._items[scheme][checksum].children
|
||||
for child_checksum, item in src.items():
|
||||
dst[child_checksum].update(item, suffix=suffix)
|
||||
|
||||
for repo_pair, files in cache.external.items():
|
||||
self.external[repo_pair].update(files)
|
||||
|
||||
def add_external(self, url, rev, path):
|
||||
self.external[url, rev].add(path)
|
||||
|
||||
def update(self, cache, suffix=""):
|
||||
for scheme, src in cache._items.items():
|
||||
dst = self._items[scheme]
|
||||
for checksum, item in src.items():
|
||||
dst[checksum].update(item, suffix=suffix)
|
||||
|
||||
for repo_pair, files in cache.external.items():
|
||||
self.external[repo_pair].update(files)
|
||||
|
||||
def scheme_keys(self, scheme):
|
||||
"""Iterate over a flat list of all keys for the specified scheme,
|
||||
including children.
|
||||
"""
|
||||
for key, item in self._items[scheme].items():
|
||||
yield key
|
||||
yield from item.child_keys()
|
||||
|
||||
def scheme_names(self, scheme):
|
||||
"""Iterate over a flat list of checksum, names items for the specified
|
||||
scheme, including children.
|
||||
"""
|
||||
for key, item in self._items[scheme].items():
|
||||
yield key, item.names
|
||||
yield from item.child_names()
|
||||
|
||||
def dir_keys(self, scheme):
|
||||
return (
|
||||
key for key, item in self._items[scheme].items() if item.children
|
||||
)
|
||||
|
||||
def child_keys(self, scheme, checksum):
|
||||
return self._items[scheme][checksum].child_keys()
|
||||
|
||||
+43
-9
@@ -7,6 +7,7 @@ from typing import Optional
|
||||
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
from dvc.objects.file import HashFile
|
||||
from dvc.objects.tree import Tree
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,15 +19,33 @@ class ObjectDB:
|
||||
DEFAULT_CACHE_TYPES = ["copy"]
|
||||
CACHE_MODE: Optional[int] = None
|
||||
|
||||
def __init__(self, fs, **config):
|
||||
def __init__(self, fs, path_info, **config):
|
||||
from dvc.state import StateNoop
|
||||
|
||||
self.fs = fs
|
||||
self.path_info = path_info
|
||||
self.state = config.get("state", StateNoop())
|
||||
self.verify = config.get("verify", self.DEFAULT_VERIFY)
|
||||
self.cache_types = config.get("type") or copy(self.DEFAULT_CACHE_TYPES)
|
||||
self.cache_type_confirmed = False
|
||||
self.slow_link_warning = config.get("slow_link_warning", True)
|
||||
self.tmp_dir = config.get("tmp_dir")
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return {
|
||||
"state": self.state,
|
||||
"verify": self.verify,
|
||||
"type": self.cache_types,
|
||||
"slow_link_warning": self.slow_link_warning,
|
||||
"tmp_dir": self.tmp_dir,
|
||||
}
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.fs == other.fs and self.path_info == other.path_info
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.fs.scheme, self.path_info))
|
||||
|
||||
def move(self, from_info, to_info):
|
||||
self.fs.move(from_info, to_info)
|
||||
@@ -87,7 +106,7 @@ class ObjectDB:
|
||||
callback(1)
|
||||
|
||||
def hash_to_path_info(self, hash_):
|
||||
return self.fs.path_info / hash_[0:2] / hash_[2:]
|
||||
return self.path_info / hash_[0:2] / hash_[2:]
|
||||
|
||||
# Override to return path as a string instead of PathInfo for clouds
|
||||
# which support string paths (see local)
|
||||
@@ -143,12 +162,12 @@ class ObjectDB:
|
||||
def _list_paths(self, prefix=None, progress_callback=None):
|
||||
if prefix:
|
||||
if len(prefix) > 2:
|
||||
path_info = self.fs.path_info / prefix[:2] / prefix[2:]
|
||||
path_info = self.path_info / prefix[:2] / prefix[2:]
|
||||
else:
|
||||
path_info = self.fs.path_info / prefix[:2]
|
||||
path_info = self.path_info / prefix[:2]
|
||||
prefix = True
|
||||
else:
|
||||
path_info = self.fs.path_info
|
||||
path_info = self.path_info
|
||||
prefix = False
|
||||
if progress_callback:
|
||||
for file_info in self.fs.walk_files(path_info, prefix=prefix):
|
||||
@@ -316,14 +335,22 @@ class ObjectDB:
|
||||
pass
|
||||
|
||||
def gc(self, used, jobs=None):
|
||||
used_hashes = set()
|
||||
for obj in used:
|
||||
used_hashes.add(obj.hash_info.value)
|
||||
if isinstance(obj, Tree):
|
||||
used_hashes.update(
|
||||
entry_obj.hash_info.value for _, entry_obj in obj
|
||||
)
|
||||
|
||||
removed = False
|
||||
# hashes must be sorted to ensure we always remove .dir files first
|
||||
for hash_ in sorted(
|
||||
self.all(jobs, str(self.fs.path_info)),
|
||||
self.all(jobs, str(self.path_info)),
|
||||
key=self.fs.is_dir_hash,
|
||||
reverse=True,
|
||||
):
|
||||
if hash_ in used:
|
||||
if hash_ in used_hashes:
|
||||
continue
|
||||
path_info = self.hash_to_path_info(hash_)
|
||||
if self.fs.is_dir_hash(hash_):
|
||||
@@ -399,8 +426,15 @@ class ObjectDB:
|
||||
# hashes_exist() (see ssh, local)
|
||||
assert self.fs.TRAVERSE_PREFIX_LEN >= 2
|
||||
|
||||
# During the tests, for ensuring that the traverse behavior
|
||||
# is working we turn on this option. It will ensure the
|
||||
# list_hashes_traverse() is called.
|
||||
always_traverse = getattr(self.fs, "_ALWAYS_TRAVERSE", False)
|
||||
|
||||
hashes = set(hashes)
|
||||
if len(hashes) == 1 or not self.fs.CAN_TRAVERSE:
|
||||
if (
|
||||
len(hashes) == 1 or not self.fs.CAN_TRAVERSE
|
||||
) and not always_traverse:
|
||||
remote_hashes = self.list_hashes_exists(hashes, jobs, name)
|
||||
return remote_hashes
|
||||
|
||||
@@ -418,7 +452,7 @@ class ObjectDB:
|
||||
)
|
||||
else:
|
||||
traverse_weight = traverse_pages
|
||||
if len(hashes) < traverse_weight:
|
||||
if len(hashes) < traverse_weight and not always_traverse:
|
||||
logger.debug(
|
||||
"Large remote ('{}' hashes < '{}' traverse weight), "
|
||||
"using object_exists for remaining hashes".format(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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
|
||||
@@ -22,9 +22,9 @@ class LocalObjectDB(ObjectDB):
|
||||
CACHE_MODE = 0o444
|
||||
UNPACKED_DIR_SUFFIX = ".unpacked"
|
||||
|
||||
def __init__(self, fs, **config):
|
||||
super().__init__(fs, **config)
|
||||
self.cache_dir = config.get("url")
|
||||
def __init__(self, fs, path_info, **config):
|
||||
super().__init__(fs, path_info, **config)
|
||||
self.cache_dir = path_info.fspath
|
||||
|
||||
shared = config.get("shared")
|
||||
if shared:
|
||||
@@ -36,11 +36,11 @@ class LocalObjectDB(ObjectDB):
|
||||
|
||||
@property
|
||||
def cache_dir(self):
|
||||
return self.fs.path_info.fspath if self.fs.path_info else None
|
||||
return self.path_info.fspath if self.path_info else None
|
||||
|
||||
@cache_dir.setter
|
||||
def cache_dir(self, value):
|
||||
self.fs.path_info = PathInfo(value) if value else None
|
||||
self.path_info = PathInfo(value) if value else None
|
||||
|
||||
@cached_property
|
||||
def cache_path(self):
|
||||
@@ -81,13 +81,13 @@ class LocalObjectDB(ObjectDB):
|
||||
return ret
|
||||
|
||||
def _list_paths(self, prefix=None, progress_callback=None):
|
||||
assert self.fs.path_info is not None
|
||||
assert self.path_info is not None
|
||||
if prefix:
|
||||
path_info = self.fs.path_info / prefix[:2]
|
||||
path_info = self.path_info / prefix[:2]
|
||||
if not self.fs.exists(path_info):
|
||||
return
|
||||
else:
|
||||
path_info = self.fs.path_info
|
||||
path_info = self.path_info
|
||||
# NOTE: use utils.fs walk_files since fs.walk_files will not follow
|
||||
# symlinks
|
||||
if progress_callback:
|
||||
|
||||
@@ -28,7 +28,7 @@ class SSHObjectDB(ObjectDB):
|
||||
callback(path)
|
||||
return ret
|
||||
|
||||
with self.fs.ssh(path_infos[0]) as ssh:
|
||||
with self.fs.ssh() as ssh:
|
||||
channels = ssh.open_max_sftp_channels()
|
||||
max_workers = len(channels)
|
||||
|
||||
@@ -75,10 +75,10 @@ class SSHObjectDB(ObjectDB):
|
||||
|
||||
def _list_paths(self, prefix=None, progress_callback=None):
|
||||
if prefix:
|
||||
root = posixpath.join(self.fs.path_info.path, prefix[:2])
|
||||
root = posixpath.join(self.path_info.path, prefix[:2])
|
||||
else:
|
||||
root = self.fs.path_info.path
|
||||
with self.fs.ssh(self.fs.path_info) as ssh:
|
||||
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
|
||||
|
||||
+24
-4
@@ -1,23 +1,34 @@
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from .errors import ObjectFormatError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.base import BaseFileSystem
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.types import DvcPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HashFile:
|
||||
def __init__(self, path_info, fs, hash_info):
|
||||
def __init__(
|
||||
self,
|
||||
path_info: Optional["DvcPath"],
|
||||
fs: Optional["BaseFileSystem"],
|
||||
hash_info: "HashInfo",
|
||||
name: Optional[str] = None,
|
||||
):
|
||||
self.path_info = path_info
|
||||
self.fs = fs
|
||||
self.hash_info = hash_info
|
||||
self.name = name
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
if not (self.path_info and self.fs):
|
||||
return None
|
||||
return self.fs.getsize(self.path_info)
|
||||
return self.hash_info.size
|
||||
|
||||
def __len__(self):
|
||||
return 1
|
||||
@@ -37,6 +48,15 @@ class HashFile:
|
||||
and self.hash_info == other.hash_info
|
||||
)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(
|
||||
(
|
||||
self.hash_info,
|
||||
self.path_info,
|
||||
self.fs.scheme if self.fs else None,
|
||||
)
|
||||
)
|
||||
|
||||
def check(self, odb, check_hash=True):
|
||||
from .stage import get_file_hash
|
||||
|
||||
|
||||
+5
-16
@@ -1,5 +1,3 @@
|
||||
import errno
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial
|
||||
|
||||
@@ -15,11 +13,11 @@ def _upload_file(path_info, fs, odb):
|
||||
from dvc.utils import tmp_fname
|
||||
from dvc.utils.stream import HashedStreamReader
|
||||
|
||||
tmp_info = odb.fs.path_info / tmp_fname()
|
||||
tmp_info = 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(
|
||||
stream, tmp_info, desc=path_info.name, total=fs.getsize(path_info)
|
||||
stream, tmp_info, desc=path_info.name, size=fs.getsize(path_info)
|
||||
)
|
||||
|
||||
obj = HashFile(tmp_info, odb.fs, stream.hash_info)
|
||||
@@ -52,11 +50,6 @@ def get_file_hash(path_info, fs, name, state=None):
|
||||
if hash_info:
|
||||
return hash_info
|
||||
|
||||
if not fs.exists(path_info):
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), path_info
|
||||
)
|
||||
|
||||
hash_info = _get_file_hash(path_info, fs, name)
|
||||
|
||||
if state:
|
||||
@@ -179,11 +172,7 @@ def stage(odb, path_info, fs, name, upload=False, **kwargs):
|
||||
isinstance(path_info, str) or path_info.scheme == fs.scheme
|
||||
)
|
||||
|
||||
if not fs.exists(path_info):
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), path_info
|
||||
)
|
||||
|
||||
details = fs.info(path_info)
|
||||
state = odb.state
|
||||
# pylint: disable=assignment-from-none
|
||||
hash_info = state.get(path_info, fs)
|
||||
@@ -215,12 +204,12 @@ def stage(odb, path_info, fs, name, upload=False, **kwargs):
|
||||
obj.hash_info.size = hash_info.size
|
||||
return obj
|
||||
|
||||
if fs.isdir(path_info):
|
||||
if details["type"] == "directory":
|
||||
obj = _get_tree_obj(path_info, fs, name, odb, state, upload, **kwargs)
|
||||
else:
|
||||
_, obj = _get_file_obj(path_info, fs, name, odb, state, upload)
|
||||
|
||||
if obj.hash_info and fs.exists(path_info):
|
||||
if obj.hash_info:
|
||||
state.save(path_info, fs, obj.hash_info)
|
||||
|
||||
return obj
|
||||
|
||||
+34
-13
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import posixpath
|
||||
from typing import TYPE_CHECKING, Optional, Tuple
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
@@ -8,6 +9,9 @@ from .errors import ObjectFormatError
|
||||
from .file import HashFile
|
||||
from .stage import get_file_hash
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .db.base import ObjectDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -24,13 +28,6 @@ class Tree(HashFile):
|
||||
|
||||
return Trie(self._dict)
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
try:
|
||||
return sum(obj.size for _, obj in self)
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
def add(self, key, obj):
|
||||
self.__dict__.pop("trie", None)
|
||||
self._dict[key] = obj
|
||||
@@ -48,7 +45,10 @@ class Tree(HashFile):
|
||||
self.path_info = path_info
|
||||
self.hash_info = get_file_hash(path_info, memfs, "md5")
|
||||
self.hash_info.value += ".dir"
|
||||
self.hash_info.size = self.size
|
||||
try:
|
||||
self.hash_info.size = sum(obj.size for _, obj in self)
|
||||
except TypeError:
|
||||
self.hash_info.size = None
|
||||
self.hash_info.nfiles = len(self)
|
||||
|
||||
def __len__(self):
|
||||
@@ -115,24 +115,45 @@ class Tree(HashFile):
|
||||
tree = cls.from_list(raw)
|
||||
tree.path_info = obj.path_info
|
||||
tree.fs = obj.fs
|
||||
for _, entry_obj in tree:
|
||||
entry_obj.fs = obj.fs
|
||||
tree.hash_info = hash_info
|
||||
|
||||
return tree
|
||||
|
||||
def filter(self, odb, prefix):
|
||||
def filter(
|
||||
self, odb: "ObjectDB", prefix: Tuple[str], copy: bool = False
|
||||
) -> Optional[HashFile]:
|
||||
"""Return filter object(s) for this tree.
|
||||
|
||||
If copy is True, returned object will be a Tree containing
|
||||
filtered entries, but with hash_info copied from the original tree.
|
||||
|
||||
If copy is False, returned object will be a raw HashFile or Tree with
|
||||
newly computed hash_info for the filtered object.
|
||||
"""
|
||||
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
|
||||
|
||||
depth = len(prefix)
|
||||
tree = Tree(None, None, None)
|
||||
if copy:
|
||||
tree = Tree(self.path_info, self.fs, self.hash_info)
|
||||
depth = 0
|
||||
else:
|
||||
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
|
||||
tree.digest()
|
||||
odb.add(tree.path_info, tree.fs, tree.hash_info)
|
||||
if not copy:
|
||||
tree.digest()
|
||||
odb.add(tree.path_info, tree.fs, tree.hash_info)
|
||||
return tree
|
||||
|
||||
|
||||
|
||||
+57
-74
@@ -2,7 +2,7 @@ import logging
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from copy import copy
|
||||
from typing import Type
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Set, Type
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from funcy import collecting, project
|
||||
@@ -24,14 +24,18 @@ from .fs.local import LocalFileSystem
|
||||
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.db import NamedCache
|
||||
from .objects.errors import ObjectFormatError
|
||||
from .objects.stage import stage as ostage
|
||||
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__)
|
||||
|
||||
|
||||
@@ -41,18 +45,24 @@ CHECKSUM_SCHEMA = Any(
|
||||
And(Any(str, And(int, Coerce(str))), Length(min=3), Lower),
|
||||
)
|
||||
|
||||
CASE_SENSITIVE_CHECKSUM_SCHEMA = Any(
|
||||
None,
|
||||
And(str, Length(max=0), SetTo(None)),
|
||||
And(Any(str, And(int, Coerce(str))), Length(min=3)),
|
||||
)
|
||||
|
||||
# NOTE: currently there are only 3 possible checksum names:
|
||||
#
|
||||
# 1) md5 (LOCAL, SSH);
|
||||
# 2) etag (S3);
|
||||
# 2) etag (S3, GS, OSS, AZURE, HTTP);
|
||||
# 3) checksum (HDFS);
|
||||
#
|
||||
# so when a few types of outputs share the same name, we only need
|
||||
# specify it once.
|
||||
CHECKSUMS_SCHEMA = {
|
||||
LocalFileSystem.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
|
||||
S3FileSystem.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
|
||||
HDFSFileSystem.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
|
||||
S3FileSystem.PARAM_CHECKSUM: CASE_SENSITIVE_CHECKSUM_SCHEMA,
|
||||
}
|
||||
|
||||
|
||||
@@ -261,8 +271,6 @@ class Output:
|
||||
IsStageFileError = OutputIsStageFileError # type: Type[DvcException]
|
||||
IsIgnoredError = OutputIsIgnoredError # type: Type[DvcException]
|
||||
|
||||
sep = "/"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stage,
|
||||
@@ -279,9 +287,8 @@ class Output:
|
||||
):
|
||||
self.repo = stage.repo if stage else None
|
||||
|
||||
fs_cls, fs_config = get_cloud_fs(self.repo, url=path)
|
||||
fs_cls, fs_config, path_info = get_cloud_fs(self.repo, url=path)
|
||||
self.fs = fs_cls(**fs_config)
|
||||
path_info = self.fs.path_info
|
||||
|
||||
if (
|
||||
self.fs.scheme == "local"
|
||||
@@ -320,6 +327,8 @@ class Output:
|
||||
self.obj = None
|
||||
self.isexec = False if self.IS_DEPENDENCY else isexec
|
||||
|
||||
self.def_remote = None
|
||||
|
||||
def _parse_path(self, fs, path_info):
|
||||
if fs.scheme != "local":
|
||||
return path_info
|
||||
@@ -648,7 +657,7 @@ class Output:
|
||||
def download(self, to, jobs=None):
|
||||
self.fs.download(self.path_info, to.path_info, jobs=jobs)
|
||||
|
||||
def get_obj(self, filter_info=None):
|
||||
def get_obj(self, filter_info=None, **kwargs):
|
||||
if self.obj:
|
||||
obj = self.obj
|
||||
elif self.hash_info:
|
||||
@@ -661,7 +670,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)
|
||||
obj = obj.filter(self.odb, prefix, **kwargs)
|
||||
|
||||
return obj
|
||||
|
||||
@@ -741,9 +750,8 @@ class Output:
|
||||
if odb is None:
|
||||
odb = self.odb
|
||||
|
||||
cls, config = get_cloud_fs(self.repo, url=source)
|
||||
cls, config, from_info = get_cloud_fs(self.repo, url=source)
|
||||
from_fs = cls(**config)
|
||||
from_info = from_fs.path_info
|
||||
|
||||
# When running import-url --to-remote / add --to-remote/-o ... we
|
||||
# assume that it is unlikely that the odb will contain majority of the
|
||||
@@ -788,18 +796,14 @@ class Output:
|
||||
self.odb.unprotect(self.path_info)
|
||||
|
||||
def get_dir_cache(self, **kwargs):
|
||||
|
||||
if not self.is_dir_checksum:
|
||||
raise DvcException("cannot get dir cache for file checksum")
|
||||
|
||||
obj = self.odb.get(self.hash_info)
|
||||
try:
|
||||
objects.check(self.odb, self.odb.get(self.hash_info))
|
||||
objects.check(self.odb, obj)
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
self.repo.cloud.pull(
|
||||
NamedCache.make("local", self.hash_info.value, str(self)),
|
||||
show_checksums=False,
|
||||
**kwargs,
|
||||
)
|
||||
self.repo.cloud.pull([obj], show_checksums=False, **kwargs)
|
||||
|
||||
try:
|
||||
self.obj = objects.load(self.odb, self.hash_info)
|
||||
@@ -810,27 +814,8 @@ class Output:
|
||||
|
||||
def collect_used_dir_cache(
|
||||
self, remote=None, force=False, jobs=None, filter_info=None
|
||||
):
|
||||
"""Get a list of `info`s related to the given directory.
|
||||
|
||||
- Pull the directory entry from the remote cache if it was changed.
|
||||
|
||||
Example:
|
||||
|
||||
Given the following commands:
|
||||
|
||||
$ echo "foo" > directory/foo
|
||||
$ echo "bar" > directory/bar
|
||||
$ dvc add directory
|
||||
|
||||
It will return a NamedCache like:
|
||||
|
||||
nc = NamedCache()
|
||||
nc.add(self.scheme, 'c157a79031e1', 'directory/foo')
|
||||
nc.add(self.scheme, 'd3b07384d113', 'directory/bar')
|
||||
"""
|
||||
|
||||
cache = NamedCache()
|
||||
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
|
||||
"""Fetch dir cache and return used objects for this out."""
|
||||
|
||||
try:
|
||||
self.get_dir_cache(jobs=jobs, remote=remote)
|
||||
@@ -850,38 +835,22 @@ class Output:
|
||||
"unable to fully collect used cache"
|
||||
" without cache for directory '{}'".format(self)
|
||||
)
|
||||
return cache
|
||||
return {}
|
||||
|
||||
path = str(self.path_info)
|
||||
filter_path = str(filter_info) if filter_info else None
|
||||
for entry_key, entry_obj in self.obj:
|
||||
entry_path = os.path.join(path, *entry_key)
|
||||
if (
|
||||
not filter_path
|
||||
or entry_path == filter_path
|
||||
or entry_path.startswith(filter_path + os.sep)
|
||||
):
|
||||
cache.add(self.scheme, entry_obj.hash_info.value, entry_path)
|
||||
obj = self.get_obj(filter_info=filter_info, copy=True)
|
||||
self._set_obj_names(obj)
|
||||
return {None: {obj}}
|
||||
|
||||
return cache
|
||||
|
||||
def get_used_cache(self, **kwargs):
|
||||
"""Get a dumpd of the given `out`, with an entry including the branch.
|
||||
|
||||
The `used_cache` of an output is no more than its `info`.
|
||||
|
||||
In case that the given output is a directory, it will also
|
||||
include the `info` of its files.
|
||||
"""
|
||||
def get_used_objs(
|
||||
self, **kwargs
|
||||
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
|
||||
"""Return filtered set of used objects for this out."""
|
||||
|
||||
if not self.use_cache:
|
||||
return NamedCache()
|
||||
return {}
|
||||
|
||||
if self.stage.is_repo_import:
|
||||
cache = NamedCache()
|
||||
(dep,) = self.stage.deps
|
||||
cache.external[dep.repo_pair].add(dep.def_path)
|
||||
return cache
|
||||
return self.get_used_external(**kwargs)
|
||||
|
||||
if not self.hash_info:
|
||||
msg = (
|
||||
@@ -900,18 +869,32 @@ class Output:
|
||||
)
|
||||
)
|
||||
logger.warning(msg)
|
||||
return NamedCache()
|
||||
return {}
|
||||
|
||||
ret = NamedCache.make(self.scheme, self.hash_info.value, str(self))
|
||||
if self.is_dir_checksum:
|
||||
return self.collect_used_dir_cache(**kwargs)
|
||||
|
||||
if not self.is_dir_checksum:
|
||||
return ret
|
||||
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)
|
||||
|
||||
ret.add_child_cache(
|
||||
self.hash_info.value, self.collect_used_dir_cache(**kwargs)
|
||||
)
|
||||
return {None: {obj}}
|
||||
|
||||
return ret
|
||||
def _set_obj_names(self, obj):
|
||||
obj.name = str(self)
|
||||
if isinstance(obj, Tree):
|
||||
for key, entry_obj in obj:
|
||||
entry_obj.name = self.fs.sep.join([obj.name, *key])
|
||||
|
||||
def get_used_external(
|
||||
self, **kwargs
|
||||
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
|
||||
if not self.use_cache or not self.stage.is_repo_import:
|
||||
return {}
|
||||
|
||||
(dep,) = self.stage.deps
|
||||
return dep.get_used_objs()
|
||||
|
||||
def _validate_output_path(self, path, stage=None):
|
||||
from dvc.dvcfile import is_valid_filename
|
||||
|
||||
+9
-5
@@ -6,6 +6,7 @@ from threading import RLock
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from dvc.env import DVC_IGNORE_ISATTY
|
||||
from dvc.utils import env2bool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -50,7 +51,7 @@ class Tqdm(tqdm):
|
||||
file=None,
|
||||
total=None,
|
||||
postfix=None,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
bytes : shortcut for
|
||||
@@ -77,7 +78,7 @@ class Tqdm(tqdm):
|
||||
# auto-disable based on TTY
|
||||
if (
|
||||
not disable
|
||||
and not env2bool("DVC_IGNORE_ISATTY")
|
||||
and not env2bool(DVC_IGNORE_ISATTY)
|
||||
and hasattr(file, "isatty")
|
||||
):
|
||||
disable = not file.isatty()
|
||||
@@ -89,7 +90,7 @@ class Tqdm(tqdm):
|
||||
bar_format="!",
|
||||
lock_args=(False,),
|
||||
total=total,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
)
|
||||
self.postfix = postfix or {"info": ""}
|
||||
if bar_format is None:
|
||||
@@ -105,13 +106,16 @@ class Tqdm(tqdm):
|
||||
self.bar_format = bar_format
|
||||
self.refresh()
|
||||
|
||||
def update_msg(self, msg, n=1):
|
||||
def update_msg(self, msg: str, n: int = 1) -> None:
|
||||
"""
|
||||
Sets `msg` as a postfix and calls `update(n)`.
|
||||
"""
|
||||
self.postfix["info"] = " %s |" % msg
|
||||
self.set_msg(msg)
|
||||
self.update(n)
|
||||
|
||||
def set_msg(self, msg: str) -> None:
|
||||
self.postfix["info"] = f" {msg} |"
|
||||
|
||||
def update_to(self, current, total=None):
|
||||
if total:
|
||||
self.total = total
|
||||
|
||||
@@ -4,8 +4,8 @@ from .local import LocalRemote
|
||||
|
||||
|
||||
def get_remote(repo, **kwargs):
|
||||
cls, config = get_cloud_fs(repo, **kwargs)
|
||||
cls, config, path_info = get_cloud_fs(repo, **kwargs)
|
||||
fs = cls(**config)
|
||||
if fs.scheme == "local":
|
||||
return LocalRemote(fs, repo.tmp_dir, **config)
|
||||
return Remote(fs, repo.tmp_dir, **config)
|
||||
return LocalRemote(fs, path_info, repo.tmp_dir, **config)
|
||||
return Remote(fs, path_info, repo.tmp_dir, **config)
|
||||
|
||||
+92
-49
@@ -8,9 +8,10 @@ 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, RemoteIndexNoop
|
||||
from .index import RemoteIndex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,25 +68,39 @@ class Remote:
|
||||
|
||||
INDEX_CLS = RemoteIndex
|
||||
|
||||
def __init__(self, fs, tmp_dir, **config):
|
||||
def __init__(self, fs, path_info, tmp_dir, **config):
|
||||
from dvc.objects.db import get_odb
|
||||
|
||||
self.fs = fs
|
||||
self.odb = get_odb(self.fs, **config)
|
||||
config["tmp_dir"] = tmp_dir
|
||||
self.odb = get_odb(self.fs, path_info, **config)
|
||||
|
||||
url = config.get("url")
|
||||
if 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
|
||||
)
|
||||
else:
|
||||
self.index = RemoteIndexNoop()
|
||||
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.fs.path_info or "No path",
|
||||
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
|
||||
@@ -101,7 +116,7 @@ class Remote:
|
||||
def status(
|
||||
self,
|
||||
cache,
|
||||
named_cache,
|
||||
objs,
|
||||
jobs=None,
|
||||
show_checksums=False,
|
||||
download=False,
|
||||
@@ -110,7 +125,7 @@ class Remote:
|
||||
# Return flattened dict containing all status info
|
||||
dir_status, file_status, _ = self._status(
|
||||
cache,
|
||||
named_cache,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=download,
|
||||
@@ -129,10 +144,10 @@ class Remote:
|
||||
|
||||
return indexed_hashes + self.odb.hashes_exist(list(hashes), **kwargs)
|
||||
|
||||
def _status(
|
||||
def _status( # pylint: disable=unused-argument
|
||||
self,
|
||||
cache,
|
||||
named_cache,
|
||||
objs,
|
||||
jobs=None,
|
||||
show_checksums=False,
|
||||
download=False,
|
||||
@@ -145,8 +160,18 @@ class Remote:
|
||||
{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.fs.path_info}")
|
||||
md5s = set(named_cache.scheme_keys(cache.fs.scheme))
|
||||
|
||||
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(
|
||||
@@ -162,21 +187,18 @@ class Remote:
|
||||
else:
|
||||
logger.debug("Collecting information from remote cache...")
|
||||
remote_exists = set()
|
||||
dir_md5s = set(named_cache.dir_keys(cache.fs.scheme))
|
||||
if dir_md5s:
|
||||
remote_exists.update(
|
||||
self._indexed_dir_hashes(cache, named_cache, dir_md5s)
|
||||
)
|
||||
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.fs.path_info)
|
||||
md5s, jobs=jobs, name=str(self.odb.path_info)
|
||||
)
|
||||
)
|
||||
return self._make_status(
|
||||
cache,
|
||||
named_cache,
|
||||
objs,
|
||||
show_checksums,
|
||||
local_exists,
|
||||
remote_exists,
|
||||
@@ -186,29 +208,36 @@ class Remote:
|
||||
def _make_status(
|
||||
self,
|
||||
cache,
|
||||
named_cache,
|
||||
objs,
|
||||
show_checksums,
|
||||
local_exists,
|
||||
remote_exists,
|
||||
log_missing,
|
||||
):
|
||||
def make_names(hash_, names):
|
||||
return {"name": hash_ if show_checksums else " ".join(names)}
|
||||
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 hash_, item in named_cache[cache.fs.scheme].items():
|
||||
if item.children:
|
||||
dir_status[hash_] = make_names(hash_, item.names)
|
||||
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_hash, child in item.children.items():
|
||||
file_status[child_hash] = make_names(
|
||||
child_hash, child.names
|
||||
)
|
||||
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(hash_, item.names)
|
||||
file_status[hash_] = make_names(obj)
|
||||
|
||||
self._fill_statuses(dir_status, local_exists, remote_exists)
|
||||
self._fill_statuses(file_status, local_exists, remote_exists)
|
||||
@@ -218,9 +247,10 @@ class Remote:
|
||||
|
||||
return dir_status, file_status, dir_contents
|
||||
|
||||
def _indexed_dir_hashes(self, cache, named_cache, dir_md5s):
|
||||
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:
|
||||
@@ -242,9 +272,9 @@ class Remote:
|
||||
# If .dir hash exists on the remote, assume directory contents
|
||||
# still exists on the remote
|
||||
for dir_hash in dir_exists:
|
||||
file_hashes = list(
|
||||
named_cache.child_keys(cache.fs.scheme, dir_hash)
|
||||
)
|
||||
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(
|
||||
@@ -294,7 +324,7 @@ class Remote:
|
||||
def _process(
|
||||
self,
|
||||
cache,
|
||||
named_cache,
|
||||
objs,
|
||||
jobs=None,
|
||||
show_checksums=False,
|
||||
download=False,
|
||||
@@ -302,7 +332,7 @@ class Remote:
|
||||
logger.debug(
|
||||
"Preparing to {} '{}'".format(
|
||||
"download data from" if download else "upload data to",
|
||||
self.fs.path_info,
|
||||
self.odb.path_info,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -320,7 +350,7 @@ class Remote:
|
||||
|
||||
dir_status, file_status, dir_contents = self._status(
|
||||
cache,
|
||||
named_cache,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=download,
|
||||
@@ -460,17 +490,20 @@ class Remote:
|
||||
self.index.update([dir_hash], file_hashes)
|
||||
|
||||
@index_locked
|
||||
def push(self, cache, named_cache, jobs=None, show_checksums=False):
|
||||
def push(self, cache, objs, jobs=None, show_checksums=False):
|
||||
ret = self._process(
|
||||
cache,
|
||||
named_cache,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=False,
|
||||
)
|
||||
|
||||
if self.fs.scheme == "local":
|
||||
for checksum in named_cache.scheme_keys("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)
|
||||
@@ -480,17 +513,19 @@ class Remote:
|
||||
return ret
|
||||
|
||||
@index_locked
|
||||
def pull(self, cache, named_cache, jobs=None, show_checksums=False):
|
||||
def pull(self, cache, objs, jobs=None, show_checksums=False):
|
||||
ret = self._process(
|
||||
cache,
|
||||
named_cache,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=True,
|
||||
)
|
||||
|
||||
if not self.odb.verify:
|
||||
for checksum in named_cache.scheme_keys("local"):
|
||||
|
||||
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
|
||||
@@ -501,6 +536,14 @@ class Remote:
|
||||
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
|
||||
|
||||
+25
-14
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Callable, Optional
|
||||
@@ -340,7 +341,7 @@ class Repo:
|
||||
existing_stages = self.stages if old_stages is None else old_stages
|
||||
build_graph(existing_stages + new_stages)
|
||||
|
||||
def used_cache(
|
||||
def used_objs(
|
||||
self,
|
||||
targets=None,
|
||||
all_branches=False,
|
||||
@@ -366,13 +367,22 @@ class Repo:
|
||||
the scope.
|
||||
|
||||
Returns:
|
||||
A dictionary with Schemes (representing output's location) mapped
|
||||
to items containing the output's `dumpd` names and the output's
|
||||
children (if the given output is a directory).
|
||||
A dict mapping (remote) ODB instances to sets of objects that
|
||||
belong to each ODB. If the ODB instance is None, the objects
|
||||
are naive and do not belong to a specific remote ODB.
|
||||
"""
|
||||
from dvc.objects.db import NamedCache
|
||||
used = defaultdict(set)
|
||||
|
||||
cache = NamedCache()
|
||||
def _add_suffix(objs, suffix):
|
||||
from dvc.objects.tree import Tree
|
||||
|
||||
for obj in 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,
|
||||
@@ -390,23 +400,24 @@ class Repo:
|
||||
for target in targets
|
||||
)
|
||||
|
||||
suffix = f"({branch})" if branch else ""
|
||||
for stage, filter_info in pairs:
|
||||
used_cache = stage.get_used_cache(
|
||||
for odb, objs in stage.get_used_objs(
|
||||
remote=remote,
|
||||
force=force,
|
||||
jobs=jobs,
|
||||
filter_info=filter_info,
|
||||
)
|
||||
cache.update(used_cache, suffix=suffix)
|
||||
).items():
|
||||
if branch:
|
||||
_add_suffix(objs, f" ({branch})")
|
||||
used[odb].update(objs)
|
||||
|
||||
if used_run_cache:
|
||||
used_cache = self.stage_cache.get_used_cache(
|
||||
for odb, objs in self.stage_cache.get_used_objs(
|
||||
used_run_cache, remote=remote, force=force, jobs=jobs
|
||||
)
|
||||
cache.update(used_cache)
|
||||
).items():
|
||||
used[odb].update(objs)
|
||||
|
||||
return cache
|
||||
return used
|
||||
|
||||
@cached_property
|
||||
def outs_trie(self):
|
||||
|
||||
+199
-212
@@ -1,9 +1,13 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
from contextlib import contextmanager
|
||||
from itertools import tee
|
||||
from typing import TYPE_CHECKING, Any, Iterator, List
|
||||
|
||||
import colorama
|
||||
|
||||
from dvc.ui import ui
|
||||
|
||||
from ..exceptions import (
|
||||
CacheLinkError,
|
||||
InvalidArgumentError,
|
||||
@@ -11,46 +15,51 @@ from ..exceptions import (
|
||||
OverlappingOutputPathsError,
|
||||
RecursiveAddingWhileUsingFilename,
|
||||
)
|
||||
from ..progress import Tqdm
|
||||
from ..repo.scm_context import scm_context
|
||||
from ..utils import LARGE_DIR_SIZE, glob_targets, resolve_output, resolve_paths
|
||||
from ..utils.collections import ensure_list, validate
|
||||
from . import locked
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo import Repo
|
||||
from dvc.stage import Stage
|
||||
from dvc.types import TargetType
|
||||
|
||||
|
||||
Stages = List["Stage"]
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@locked
|
||||
@scm_context
|
||||
def add( # noqa: C901
|
||||
repo,
|
||||
targets: "TargetType",
|
||||
recursive=False,
|
||||
no_commit=False,
|
||||
fname=None,
|
||||
to_remote=False,
|
||||
**kwargs,
|
||||
):
|
||||
from dvc.utils.collections import ensure_list
|
||||
OVERLAPPING_OUTPUT_FMT = (
|
||||
"Cannot add '{out}', because it is overlapping with other "
|
||||
"DVC tracked output: '{parent}'.\n"
|
||||
"To include '{out}' in '{parent}', run "
|
||||
"'dvc commit {parent_stage}'"
|
||||
)
|
||||
|
||||
if recursive and fname:
|
||||
|
||||
def check_recursive_and_fname(args):
|
||||
if args.recursive and args.fname:
|
||||
raise RecursiveAddingWhileUsingFilename()
|
||||
|
||||
targets = ensure_list(targets)
|
||||
|
||||
to_cache = kwargs.get("out") and not to_remote
|
||||
def transform_targets(args):
|
||||
args.targets = ensure_list(args.targets)
|
||||
|
||||
|
||||
def check_arg_combinations(args):
|
||||
kwargs = args.kwargs
|
||||
invalid_opt = None
|
||||
to_remote = args.to_remote
|
||||
to_cache = kwargs.get("out") and not to_remote
|
||||
|
||||
if to_remote or to_cache:
|
||||
message = "{option} can't be used with "
|
||||
message += "--to-remote" if to_remote else "-o"
|
||||
if len(targets) != 1:
|
||||
if len(args.targets) != 1:
|
||||
invalid_opt = "multiple targets"
|
||||
elif no_commit:
|
||||
elif args.no_commit:
|
||||
invalid_opt = "--no-commit option"
|
||||
elif recursive:
|
||||
elif args.recursive:
|
||||
invalid_opt = "--recursive option"
|
||||
elif kwargs.get("external"):
|
||||
invalid_opt = "--external option"
|
||||
@@ -64,214 +73,192 @@ def add( # noqa: C901
|
||||
if invalid_opt is not None:
|
||||
raise InvalidArgumentError(message.format(option=invalid_opt))
|
||||
|
||||
link_failures = []
|
||||
stages_list = []
|
||||
num_targets = len(targets)
|
||||
with Tqdm(total=num_targets, desc="Add", unit="file", leave=True) as pbar:
|
||||
if num_targets == 1:
|
||||
# clear unneeded top-level progress bar for single target
|
||||
pbar.bar_format = "Adding..."
|
||||
pbar.refresh()
|
||||
for target in targets:
|
||||
sub_targets = _find_all_targets(repo, target, recursive)
|
||||
pbar.total += len(sub_targets) - 1
|
||||
|
||||
if os.path.isdir(target) and len(sub_targets) > LARGE_DIR_SIZE:
|
||||
logger.warning(
|
||||
"You are adding a large directory '{target}' recursively,"
|
||||
" consider tracking it as a whole instead.\n"
|
||||
"{purple}HINT:{nc} Remove the generated DVC file and then"
|
||||
" run `{cyan}dvc add {target}{nc}`".format(
|
||||
purple=colorama.Fore.MAGENTA,
|
||||
cyan=colorama.Fore.CYAN,
|
||||
nc=colorama.Style.RESET_ALL,
|
||||
target=target,
|
||||
)
|
||||
)
|
||||
|
||||
stages = _create_stages(
|
||||
repo,
|
||||
sub_targets,
|
||||
fname,
|
||||
pbar=pbar,
|
||||
transfer=to_remote or to_cache,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
try:
|
||||
repo.check_modified_graph(stages)
|
||||
except OverlappingOutputPathsError as exc:
|
||||
msg = (
|
||||
"Cannot add '{out}', because it is overlapping with other "
|
||||
"DVC tracked output: '{parent}'.\n"
|
||||
"To include '{out}' in '{parent}', run "
|
||||
"'dvc commit {parent_stage}'"
|
||||
).format(
|
||||
out=exc.overlapping_out.path_info,
|
||||
parent=exc.parent.path_info,
|
||||
parent_stage=exc.parent.stage.addressing,
|
||||
)
|
||||
raise OverlappingOutputPathsError(
|
||||
exc.parent, exc.overlapping_out, msg
|
||||
)
|
||||
except OutputDuplicationError as exc:
|
||||
raise OutputDuplicationError(
|
||||
exc.output, list(set(exc.stages) - set(stages))
|
||||
)
|
||||
|
||||
link_failures.extend(
|
||||
_process_stages(
|
||||
repo,
|
||||
sub_targets,
|
||||
stages,
|
||||
no_commit,
|
||||
pbar,
|
||||
to_remote,
|
||||
to_cache,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
stages_list += stages
|
||||
|
||||
if num_targets == 1: # restore bar format for stats
|
||||
pbar.bar_format = pbar.BAR_FMT_DEFAULT
|
||||
|
||||
if link_failures:
|
||||
msg = (
|
||||
"Some targets could not be linked from cache to workspace.\n{}\n"
|
||||
"To re-link these targets, reconfigure cache types and then run:\n"
|
||||
"\n\tdvc checkout {}"
|
||||
).format(
|
||||
CacheLinkError.SUPPORT_LINK,
|
||||
" ".join([str(stage.relpath) for stage in link_failures]),
|
||||
@contextmanager
|
||||
def translate_graph_error(stages: Stages) -> Iterator[None]:
|
||||
try:
|
||||
yield
|
||||
except OverlappingOutputPathsError as exc:
|
||||
msg = OVERLAPPING_OUTPUT_FMT.format(
|
||||
out=exc.overlapping_out.path_info,
|
||||
parent=exc.parent.path_info,
|
||||
parent_stage=exc.parent.stage.addressing,
|
||||
)
|
||||
raise OverlappingOutputPathsError(exc.parent, exc.overlapping_out, msg)
|
||||
except OutputDuplicationError as exc:
|
||||
raise OutputDuplicationError(
|
||||
exc.output, list(set(exc.stages) - set(stages))
|
||||
)
|
||||
logger.warning(msg)
|
||||
|
||||
return stages_list
|
||||
|
||||
|
||||
def _process_stages(
|
||||
repo, sub_targets, stages, no_commit, pbar, to_remote, to_cache, **kwargs
|
||||
def progress_iter(stages: Stages) -> Iterator["Stage"]:
|
||||
total = len(stages)
|
||||
desc = "Adding..."
|
||||
with ui.progress(
|
||||
stages, total=total, desc=desc, unit="file", leave=True
|
||||
) as pbar:
|
||||
if total == 1:
|
||||
pbar.bar_format = desc
|
||||
pbar.refresh()
|
||||
|
||||
for stage in pbar:
|
||||
if total > 1:
|
||||
pbar.set_msg(f"{stage.outs[0]}")
|
||||
yield stage
|
||||
if total == 1: # restore bar format for stats
|
||||
# pylint: disable=no-member
|
||||
pbar.bar_format = pbar.BAR_FMT_DEFAULT
|
||||
|
||||
|
||||
LINK_FAILURE_MESSAGE = (
|
||||
"\nSome targets could not be linked from cache to workspace.\n{}\n"
|
||||
"To re-link these targets, reconfigure cache types and then run:\n"
|
||||
"\n\tdvc checkout {}"
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def warn_link_failures() -> Iterator[List[str]]:
|
||||
link_failures: List[str] = []
|
||||
try:
|
||||
yield link_failures
|
||||
finally:
|
||||
if link_failures:
|
||||
msg = LINK_FAILURE_MESSAGE.format(
|
||||
CacheLinkError.SUPPORT_LINK,
|
||||
" ".join(link_failures),
|
||||
)
|
||||
ui.error_write(msg)
|
||||
|
||||
|
||||
VALIDATORS = (
|
||||
check_recursive_and_fname,
|
||||
transform_targets,
|
||||
check_arg_combinations,
|
||||
)
|
||||
|
||||
|
||||
@validate(*VALIDATORS)
|
||||
@locked
|
||||
@scm_context
|
||||
def add( # noqa: C901
|
||||
repo: "Repo",
|
||||
targets: "TargetType",
|
||||
recursive: bool = False,
|
||||
no_commit: bool = False,
|
||||
fname: str = None,
|
||||
to_remote: bool = False,
|
||||
**kwargs: Any,
|
||||
):
|
||||
link_failures = []
|
||||
from dvc.dvcfile import Dvcfile
|
||||
to_cache = bool(kwargs.get("out")) and not to_remote
|
||||
transfer = to_remote or to_cache
|
||||
|
||||
from ..output import OutputDoesNotExistError
|
||||
glob = kwargs.get("glob", False)
|
||||
add_targets = collect_targets(repo, targets, recursive, glob)
|
||||
# pass one for creating stages, other one is used for iterating here
|
||||
add_targets, sources = tee(add_targets)
|
||||
|
||||
if to_remote or to_cache:
|
||||
# Already verified in the add()
|
||||
(stage,) = stages
|
||||
(target,) = sub_targets
|
||||
(out,) = stage.outs
|
||||
# collect targets and build stages as we go
|
||||
desc = "Collecting targets"
|
||||
stages_it = create_stages(repo, add_targets, fname, transfer, **kwargs)
|
||||
stages = list(ui.progress(stages_it, desc=desc, unit="file"))
|
||||
|
||||
odb = None
|
||||
if to_remote:
|
||||
remote = repo.cloud.get_remote(kwargs.get("remote"), "add")
|
||||
odb = remote.odb
|
||||
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)
|
||||
status.update("Checking graph")
|
||||
repo.check_modified_graph(stages, list(old_stages))
|
||||
|
||||
out.transfer(target, odb=odb, jobs=kwargs.get("jobs"))
|
||||
odb = None
|
||||
if to_remote:
|
||||
remote = repo.cloud.get_remote(kwargs.get("remote"), "add")
|
||||
odb = remote.odb
|
||||
|
||||
if to_cache:
|
||||
out.checkout()
|
||||
|
||||
Dvcfile(repo, stage.path).dump(stage)
|
||||
return link_failures
|
||||
|
||||
with Tqdm(
|
||||
total=len(stages),
|
||||
desc="Processing",
|
||||
unit="file",
|
||||
disable=len(stages) == 1,
|
||||
) as pbar_stages:
|
||||
for stage in stages:
|
||||
try:
|
||||
stage.save()
|
||||
except OutputDoesNotExistError:
|
||||
pbar.n -= 1
|
||||
raise
|
||||
|
||||
try:
|
||||
if not no_commit:
|
||||
stage.commit()
|
||||
except CacheLinkError:
|
||||
link_failures.append(stage)
|
||||
|
||||
Dvcfile(repo, stage.path).dump(stage)
|
||||
pbar_stages.update()
|
||||
|
||||
return link_failures
|
||||
with warn_link_failures() as link_failures:
|
||||
for stage, source in zip(progress_iter(stages), sources):
|
||||
if to_remote or to_cache:
|
||||
stage.transfer(source, to_remote=to_remote, odb=odb, **kwargs)
|
||||
else:
|
||||
try:
|
||||
stage.save()
|
||||
if not no_commit:
|
||||
stage.commit()
|
||||
except CacheLinkError:
|
||||
link_failures.append(str(stage.relpath))
|
||||
stage.dump()
|
||||
return stages
|
||||
|
||||
|
||||
def _find_all_targets(repo, target, recursive):
|
||||
LARGE_DIR_RECURSIVE_ADD_WARNING = (
|
||||
"You are adding a large directory '{target}' recursively.\n"
|
||||
"Consider tracking it as a whole instead with "
|
||||
"`{cyan}dvc add {target}{nc}`."
|
||||
)
|
||||
|
||||
|
||||
def collect_targets(
|
||||
repo: "Repo",
|
||||
targets: "TargetType",
|
||||
recursive: bool = False,
|
||||
glob: bool = False,
|
||||
) -> Iterator[str]:
|
||||
for target in glob_targets(ensure_list(targets), glob=glob):
|
||||
expanded_targets = _find_all_targets(repo, target, recursive=recursive)
|
||||
for index, path in enumerate(expanded_targets):
|
||||
if index == LARGE_DIR_SIZE:
|
||||
msg = LARGE_DIR_RECURSIVE_ADD_WARNING.format(
|
||||
cyan=colorama.Fore.CYAN,
|
||||
nc=colorama.Style.RESET_ALL,
|
||||
target=target,
|
||||
)
|
||||
ui.error_write(msg)
|
||||
yield path
|
||||
|
||||
|
||||
def _find_all_targets(
|
||||
repo: "Repo", target: str, recursive: bool = False
|
||||
) -> Iterator[str]:
|
||||
from dvc.dvcfile import is_dvc_file
|
||||
|
||||
if os.path.isdir(target) and recursive:
|
||||
return [
|
||||
os.fspath(path)
|
||||
for path in Tqdm(
|
||||
repo.dvcignore.walk_files(repo.fs, target),
|
||||
desc="Searching " + target,
|
||||
bar_format=Tqdm.BAR_FMT_NOTOTAL,
|
||||
unit="file",
|
||||
)
|
||||
if not repo.is_dvc_internal(os.fspath(path))
|
||||
if not is_dvc_file(os.fspath(path))
|
||||
if not repo.scm.belongs_to_scm(os.fspath(path))
|
||||
if not repo.scm.is_tracked(os.fspath(path))
|
||||
]
|
||||
return [target]
|
||||
|
||||
|
||||
def _create_stages(
|
||||
repo,
|
||||
targets,
|
||||
fname,
|
||||
pbar=None,
|
||||
external=False,
|
||||
glob=False,
|
||||
desc=None,
|
||||
transfer=False,
|
||||
**kwargs,
|
||||
):
|
||||
from dvc.dvcfile import Dvcfile
|
||||
from dvc.stage import Stage, create_stage, restore_meta
|
||||
|
||||
expanded_targets = glob_targets(targets, glob=glob)
|
||||
|
||||
stages = []
|
||||
for out in Tqdm(
|
||||
expanded_targets,
|
||||
desc="Creating DVC files",
|
||||
disable=len(expanded_targets) < LARGE_DIR_SIZE,
|
||||
unit="file",
|
||||
):
|
||||
if kwargs.get("out"):
|
||||
out = resolve_output(out, kwargs["out"])
|
||||
path, wdir, out = resolve_paths(
|
||||
repo, out, always_local=transfer and not kwargs.get("out")
|
||||
files = map(os.fspath, repo.dvcignore.walk_files(repo.fs, target))
|
||||
yield from (
|
||||
path
|
||||
for path in files
|
||||
if not repo.is_dvc_internal(path)
|
||||
if not is_dvc_file(path)
|
||||
if not repo.scm.belongs_to_scm(path)
|
||||
if not repo.scm.is_tracked(path)
|
||||
)
|
||||
stage = create_stage(
|
||||
Stage,
|
||||
repo,
|
||||
fname or path,
|
||||
else:
|
||||
yield target
|
||||
|
||||
|
||||
def create_stages(
|
||||
repo: "Repo",
|
||||
targets: Iterator[str],
|
||||
fname: str = None,
|
||||
transfer: bool = False,
|
||||
external: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Iterator["Stage"]:
|
||||
for target in targets:
|
||||
if kwargs.get("out"):
|
||||
target = resolve_output(target, kwargs["out"])
|
||||
path, wdir, out = resolve_paths(
|
||||
repo, target, always_local=transfer and not kwargs.get("out")
|
||||
)
|
||||
|
||||
stage = repo.stage.create(
|
||||
single_stage=True,
|
||||
validate=False,
|
||||
fname=fname or path,
|
||||
wdir=wdir,
|
||||
outs=[out],
|
||||
external=external,
|
||||
)
|
||||
restore_meta(stage)
|
||||
Dvcfile(repo, stage.path).remove()
|
||||
if desc:
|
||||
stage.outs[0].desc = desc
|
||||
|
||||
repo._reset() # pylint: disable=protected-access
|
||||
|
||||
if not stage:
|
||||
if pbar is not None:
|
||||
pbar.total -= 1
|
||||
continue
|
||||
|
||||
stages.append(stage)
|
||||
if pbar is not None:
|
||||
pbar.update_msg(out)
|
||||
|
||||
return stages
|
||||
if kwargs.get("desc"):
|
||||
stage.outs[0].desc = kwargs["desc"]
|
||||
yield stage
|
||||
|
||||
@@ -753,7 +753,7 @@ class Experiments:
|
||||
assert entry.index == 0
|
||||
|
||||
# NOTE: the stash commit to be popped already contains all the current
|
||||
# workspace changes plus CLI modifed --params changes.
|
||||
# workspace changes plus CLI modified --params changes.
|
||||
# `checkout --force` here will not lose any data (popping stash commit
|
||||
# will result in conflict between workspace params and stashed CLI
|
||||
# params, but we always want the stashed version).
|
||||
|
||||
+74
-44
@@ -1,9 +1,9 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from dvc.config import NoRemoteError
|
||||
from dvc.exceptions import DownloadError, NoOutputOrStageError
|
||||
from dvc.exceptions import DownloadError
|
||||
|
||||
from ..scheme import Schemes
|
||||
from . import locked
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -36,11 +36,12 @@ 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]
|
||||
|
||||
used = self.used_cache(
|
||||
used = self.used_objs(
|
||||
targets,
|
||||
all_branches=all_branches,
|
||||
all_tags=all_tags,
|
||||
@@ -59,17 +60,39 @@ def fetch(
|
||||
try:
|
||||
if run_cache:
|
||||
self.stage_cache.pull(remote)
|
||||
downloaded += self.cloud.pull(
|
||||
used, jobs, remote=remote, show_checksums=show_checksums
|
||||
)
|
||||
except NoRemoteError:
|
||||
if not used.external and used["local"]:
|
||||
raise
|
||||
except DownloadError as exc:
|
||||
failed += exc.amount
|
||||
|
||||
for (repo_url, repo_rev), files in used.external.items():
|
||||
d, f = _fetch_external(self, repo_url, repo_rev, files, jobs)
|
||||
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)
|
||||
downloaded += d
|
||||
failed += f
|
||||
|
||||
@@ -79,44 +102,51 @@ def fetch(
|
||||
return downloaded
|
||||
|
||||
|
||||
def _fetch_external(self, repo_url, repo_rev, files, jobs):
|
||||
from dvc.external_repo import external_repo
|
||||
from dvc.objects import save
|
||||
from dvc.objects.stage import stage
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.scm.base import CloneError
|
||||
|
||||
def _fetch_naive_objs(repo, objs, **kwargs):
|
||||
# objs contains naive objects to be pulled from specified remote
|
||||
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:
|
||||
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 cb(result):
|
||||
def callback(result):
|
||||
results.append(result)
|
||||
|
||||
odb = self.odb.local
|
||||
try:
|
||||
with external_repo(
|
||||
repo_url, repo_rev, cache_dir=odb.cache_dir
|
||||
) as repo:
|
||||
root = PathInfo(repo.root_dir)
|
||||
for path in files:
|
||||
path_info = root / path
|
||||
try:
|
||||
used = repo.used_cache(
|
||||
[os.fspath(path_info)],
|
||||
force=True,
|
||||
jobs=jobs,
|
||||
recursive=True,
|
||||
)
|
||||
cb(repo.cloud.pull(used, jobs))
|
||||
except (NoOutputOrStageError, NoRemoteError):
|
||||
pass
|
||||
obj = stage(odb, path_info, repo.repo_fs, "md5", jobs=jobs)
|
||||
save(odb, obj, jobs=jobs, download_callback=cb)
|
||||
except CloneError:
|
||||
failed += 1
|
||||
logger.exception(
|
||||
"failed to fetch data for '{}'".format(", ".join(files))
|
||||
)
|
||||
for obj in objs:
|
||||
try:
|
||||
save(repo.odb.local, obj, download_callback=callback, **kwargs)
|
||||
except ObjectError:
|
||||
failed += 1
|
||||
|
||||
return sum(results), failed
|
||||
|
||||
+24
-16
@@ -1,11 +1,14 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Set
|
||||
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
from dvc.objects.db import NamedCache
|
||||
|
||||
from ..scheme import Schemes
|
||||
from . import locked
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -52,30 +55,32 @@ def gc(
|
||||
repos = []
|
||||
all_repos = [Repo(path) for path in repos]
|
||||
|
||||
used_objs: Set["HashFile"] = set()
|
||||
with ExitStack() as stack:
|
||||
for repo in all_repos:
|
||||
stack.enter_context(repo.lock)
|
||||
|
||||
used = NamedCache()
|
||||
for repo in all_repos + [self]:
|
||||
used.update(
|
||||
repo.used_cache(
|
||||
all_branches=all_branches,
|
||||
with_deps=with_deps,
|
||||
all_tags=all_tags,
|
||||
all_commits=all_commits,
|
||||
all_experiments=all_experiments,
|
||||
remote=remote,
|
||||
force=force,
|
||||
jobs=jobs,
|
||||
)
|
||||
)
|
||||
for objs in repo.used_objs(
|
||||
all_branches=all_branches,
|
||||
with_deps=with_deps,
|
||||
all_tags=all_tags,
|
||||
all_commits=all_commits,
|
||||
all_experiments=all_experiments,
|
||||
remote=remote,
|
||||
force=force,
|
||||
jobs=jobs,
|
||||
).values():
|
||||
used_objs.update(objs)
|
||||
|
||||
for scheme, odb in self.odb.by_scheme():
|
||||
if not odb:
|
||||
continue
|
||||
|
||||
removed = odb.gc(set(used.scheme_keys(scheme)), jobs=jobs)
|
||||
removed = odb.gc(
|
||||
{obj for obj in used_objs if obj.fs.scheme == scheme},
|
||||
jobs=jobs,
|
||||
)
|
||||
if not removed:
|
||||
logger.info(f"No unused '{scheme}' cache to remove.")
|
||||
|
||||
@@ -83,6 +88,9 @@ def gc(
|
||||
return
|
||||
|
||||
remote = self.cloud.get_remote(remote, "gc -c")
|
||||
removed = remote.gc(set(used.scheme_keys(Schemes.LOCAL)), jobs=jobs)
|
||||
removed = remote.gc(
|
||||
{obj for obj in used_objs if obj.fs.scheme == Schemes.LOCAL},
|
||||
jobs=jobs,
|
||||
)
|
||||
if not removed:
|
||||
logger.info("No unused cache to remove from remote.")
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ def ls(url, path=None, rev=None, recursive=None, dvc_only=False):
|
||||
"""
|
||||
from . import Repo
|
||||
|
||||
with Repo.open(url, rev, subrepos=True, uninitialized=True) as repo:
|
||||
with Repo.open(url, rev=rev, subrepos=True, uninitialized=True) as repo:
|
||||
path_info = PathInfo(repo.root_dir)
|
||||
if path:
|
||||
path_info /= path
|
||||
|
||||
+5
-1
@@ -62,7 +62,11 @@ def move(self, from_path, to_path):
|
||||
)
|
||||
to_path = os.path.relpath(to_path, new_wdir)
|
||||
new_stage = self.stage.create(
|
||||
single_stage=True, fname=new_fname, wdir=new_wdir, outs=[to_path]
|
||||
single_stage=True,
|
||||
fname=new_fname,
|
||||
wdir=new_wdir,
|
||||
outs=[to_path],
|
||||
meta=stage.meta,
|
||||
)
|
||||
|
||||
os.unlink(stage.path)
|
||||
|
||||
+6
-2
@@ -24,7 +24,7 @@ def push(
|
||||
|
||||
expanded_targets = glob_targets(targets, glob=glob)
|
||||
|
||||
used = self.used_cache(
|
||||
used = self.used_objs(
|
||||
expanded_targets,
|
||||
all_branches=all_branches,
|
||||
all_tags=all_tags,
|
||||
@@ -38,4 +38,8 @@ def push(
|
||||
revs=revs,
|
||||
)
|
||||
|
||||
return len(used_run_cache) + self.cloud.push(used, jobs, remote=remote)
|
||||
pushed = len(used_run_cache)
|
||||
for odb, objs in used.items():
|
||||
if odb is None:
|
||||
pushed += self.cloud.push(objs, jobs, remote=remote)
|
||||
return pushed
|
||||
|
||||
+2
-1
@@ -462,7 +462,8 @@ class StageLoad:
|
||||
|
||||
def is_out_or_ignored(root, directory):
|
||||
dir_path = f"{root}{sep}{directory}"
|
||||
return dir_path in outs or is_ignored(dir_path)
|
||||
# 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(
|
||||
|
||||
+18
-14
@@ -78,7 +78,7 @@ def _cloud_status(
|
||||
"""
|
||||
import dvc.remote.base as cloud
|
||||
|
||||
used = self.used_cache(
|
||||
used = self.used_objs(
|
||||
targets,
|
||||
all_branches=all_branches,
|
||||
all_tags=all_tags,
|
||||
@@ -91,22 +91,26 @@ def _cloud_status(
|
||||
)
|
||||
|
||||
ret = {}
|
||||
status_info = self.cloud.status(
|
||||
used, jobs, remote=remote, log_missing=False
|
||||
)
|
||||
for info in status_info.values():
|
||||
name = info["name"]
|
||||
status_ = info["status"]
|
||||
if status_ == cloud.STATUS_OK:
|
||||
for odb, objs in used.items():
|
||||
if odb is not None:
|
||||
# ignore imported objects
|
||||
continue
|
||||
status_info = self.cloud.status(
|
||||
objs, 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",
|
||||
}
|
||||
prefix_map = {
|
||||
cloud.STATUS_DELETED: "deleted",
|
||||
cloud.STATUS_NEW: "new",
|
||||
cloud.STATUS_MISSING: "missing",
|
||||
}
|
||||
|
||||
ret[name] = prefix_map[status_]
|
||||
ret[name] = prefix_map[status_]
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ class BaseGitBackend(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def get_refs_containing(self, rev: str, pattern: Optional[str] = None):
|
||||
"""Iterate over all git refs containing the specfied revision."""
|
||||
"""Iterate over all git refs containing the specified revision."""
|
||||
|
||||
@abstractmethod
|
||||
def push_refspec(
|
||||
|
||||
@@ -27,6 +27,8 @@ from ..objects import GitObject
|
||||
from .base import BaseGitBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.types import StrPath
|
||||
|
||||
from ..objects import GitCommit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -278,12 +280,14 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
|
||||
return IgnoreFilterManager.from_repo(self.repo)
|
||||
|
||||
def is_ignored(self, path: str) -> bool:
|
||||
def is_ignored(self, path: "StrPath") -> bool:
|
||||
# `is_ignored` returns `false` if excluded in `.gitignore` and
|
||||
# `None` if it's not mentioned at all. `True` if it is ignored.
|
||||
return bool(
|
||||
self.ignore_manager.is_ignored(relpath(path, self.root_dir))
|
||||
)
|
||||
relative_path = relpath(path, self.root_dir)
|
||||
# if checking a directory, a trailing slash must be included
|
||||
if str(path)[-1] == os.sep:
|
||||
relative_path += os.sep
|
||||
return bool(self.ignore_manager.is_ignored(relative_path))
|
||||
|
||||
def set_ref(
|
||||
self,
|
||||
|
||||
@@ -3,7 +3,16 @@ import locale
|
||||
import logging
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import Callable, Iterable, List, Mapping, Optional, Tuple, Union
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Callable,
|
||||
Iterable,
|
||||
List,
|
||||
Mapping,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
from funcy import ignore
|
||||
|
||||
@@ -14,6 +23,9 @@ from dvc.utils import fix_env, is_binary, relpath
|
||||
from ..objects import GitCommit, GitObject
|
||||
from .base import BaseGitBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.types import StrPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -111,11 +123,11 @@ class GitPythonBackend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
def git(self):
|
||||
return self.repo.git
|
||||
|
||||
def is_ignored(self, path: str) -> bool:
|
||||
def is_ignored(self, path: "StrPath") -> bool:
|
||||
from git.exc import GitCommandError
|
||||
|
||||
func = ignore(GitCommandError)(self.repo.git.check_ignore)
|
||||
return bool(func(path))
|
||||
return bool(func(str(path)))
|
||||
|
||||
@property
|
||||
def root_dir(self) -> str:
|
||||
|
||||
@@ -4,7 +4,16 @@ import os
|
||||
import stat
|
||||
from contextlib import contextmanager
|
||||
from io import BytesIO, StringIO
|
||||
from typing import Callable, Iterable, List, Mapping, Optional, Tuple, Union
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Callable,
|
||||
Iterable,
|
||||
List,
|
||||
Mapping,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
@@ -17,6 +26,10 @@ from .base import BaseGitBackend
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.types import StrPath
|
||||
|
||||
|
||||
class Pygit2Object(GitObject):
|
||||
def __init__(self, obj):
|
||||
self.obj = obj
|
||||
@@ -81,6 +94,15 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
|
||||
return RefdbFsBackend(self.repo)
|
||||
|
||||
def _resolve_refish(self, refish: str):
|
||||
from pygit2 import GIT_OBJ_COMMIT, Tag
|
||||
|
||||
commit, ref = self.repo.resolve_refish(refish)
|
||||
if isinstance(commit, Tag):
|
||||
ref = commit
|
||||
commit = commit.peel(GIT_OBJ_COMMIT)
|
||||
return commit, ref
|
||||
|
||||
@property
|
||||
def default_signature(self):
|
||||
try:
|
||||
@@ -140,7 +162,7 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
if branch == "-":
|
||||
branch = "@{-1}"
|
||||
try:
|
||||
commit, ref = self.repo.resolve_refish(branch)
|
||||
commit, ref = self._resolve_refish(branch)
|
||||
except (KeyError, GitError):
|
||||
raise RevError(f"unknown Git revision '{branch}'")
|
||||
self.repo.checkout_tree(commit, strategy=checkout_strategy)
|
||||
@@ -200,7 +222,7 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
from pygit2 import GitError
|
||||
|
||||
try:
|
||||
commit, _ref = self.repo.resolve_refish(rev)
|
||||
commit, _ref = self._resolve_refish(rev)
|
||||
return str(commit.id)
|
||||
except (KeyError, GitError):
|
||||
pass
|
||||
@@ -220,7 +242,7 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
from pygit2 import GitError
|
||||
|
||||
try:
|
||||
commit, _ref = self.repo.resolve_refish(rev)
|
||||
commit, _ref = self._resolve_refish(rev)
|
||||
except (KeyError, GitError):
|
||||
raise SCMError(f"Invalid commit '{rev}'")
|
||||
return GitCommit(
|
||||
@@ -234,7 +256,7 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
def _get_stash(self, ref: str):
|
||||
raise NotImplementedError
|
||||
|
||||
def is_ignored(self, path: str) -> bool:
|
||||
def is_ignored(self, path: "StrPath") -> bool:
|
||||
rel = relpath(path, self.root_dir)
|
||||
if os.name == "nt":
|
||||
rel.replace("\\", "/")
|
||||
@@ -294,12 +316,12 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
from pygit2 import GitError
|
||||
|
||||
def _contains(repo, ref, search_commit):
|
||||
commit, _ref = self.repo.resolve_refish(ref)
|
||||
commit, _ref = self._resolve_refish(ref)
|
||||
base = repo.merge_base(search_commit.id, commit.id)
|
||||
return base == search_commit.id
|
||||
|
||||
try:
|
||||
search_commit, _ref = self.repo.resolve_refish(rev)
|
||||
search_commit, _ref = self._resolve_refish(rev)
|
||||
except (KeyError, GitError):
|
||||
raise SCMError(f"Invalid rev '{rev}'")
|
||||
|
||||
@@ -384,7 +406,7 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
|
||||
# libgit2 stash apply only accepts refs/stash items by index. If rev is
|
||||
# not in refs/stash, we will push it onto the stash, and then pop it
|
||||
commit, _ref = self.repo.resolve_refish(rev)
|
||||
commit, _ref = self._resolve_refish(rev)
|
||||
stash = self.repo.references.get(Stash.DEFAULT_STASH)
|
||||
if stash:
|
||||
for i, entry in enumerate(stash.log()):
|
||||
|
||||
+24
-8
@@ -4,7 +4,7 @@ import string
|
||||
from collections import defaultdict
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Dict, Optional
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Set
|
||||
|
||||
from funcy import cached_property, project
|
||||
|
||||
@@ -36,6 +36,8 @@ from .utils import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.dvcfile import DVCFile
|
||||
from dvc.objects.db.base import ObjectDB
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# Disallow all punctuation characters except hyphen and underscore
|
||||
@@ -388,6 +390,19 @@ class Stage(params.StageParams):
|
||||
if purge:
|
||||
self.dvcfile.remove_stage(self)
|
||||
|
||||
def transfer(
|
||||
self,
|
||||
source: str,
|
||||
odb: "ObjectDB" = None,
|
||||
to_remote: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
assert len(self.outs) == 1
|
||||
(out,) = self.outs
|
||||
out.transfer(source, odb=odb, jobs=kwargs.get("jobs"))
|
||||
if not to_remote:
|
||||
out.checkout()
|
||||
|
||||
@rwlocked(read=["deps"], write=["outs"])
|
||||
def reproduce(self, interactive=False, **kwargs):
|
||||
if not (kwargs.get("force", False) or self.changed()):
|
||||
@@ -622,14 +637,15 @@ class Stage(params.StageParams):
|
||||
for out in self.filter_outs(filter_info)
|
||||
)
|
||||
|
||||
def get_used_cache(self, *args, **kwargs):
|
||||
from dvc.objects.db import NamedCache
|
||||
|
||||
cache = NamedCache()
|
||||
def get_used_objs(
|
||||
self, *args, **kwargs
|
||||
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
|
||||
"""Return set of objects used by this stage."""
|
||||
used_objs = defaultdict(set)
|
||||
for out in self.filter_outs(kwargs.get("filter_info")):
|
||||
cache.update(out.get_used_cache(*args, **kwargs))
|
||||
|
||||
return cache
|
||||
for odb, objs in out.get_used_objs(*args, **kwargs).items():
|
||||
used_objs[odb].update(objs)
|
||||
return used_objs
|
||||
|
||||
@staticmethod
|
||||
def _check_can_merge(stage, ancestor_out=None):
|
||||
|
||||
+18
-14
@@ -196,7 +196,8 @@ class StageCache:
|
||||
cached_stage = self._create_stage(cache, wdir=stage.wdir)
|
||||
|
||||
if pull:
|
||||
self.repo.cloud.pull(cached_stage.get_used_cache())
|
||||
for objs in cached_stage.get_used_objs().values():
|
||||
self.repo.cloud.pull(objs)
|
||||
|
||||
if not cached_stage.outs_cached():
|
||||
raise RunCacheNotFoundError(stage)
|
||||
@@ -212,15 +213,17 @@ class StageCache:
|
||||
ret = []
|
||||
|
||||
runs = from_remote.path_info / "runs"
|
||||
if not from_remote.exists(runs):
|
||||
if not from_remote.fs.exists(runs):
|
||||
return []
|
||||
|
||||
for src in from_remote.walk_files(runs):
|
||||
for src in from_remote.fs.walk_files(runs):
|
||||
rel = src.relative_to(from_remote.path_info)
|
||||
dst = to_remote.path_info / rel
|
||||
key = dst.parent
|
||||
# check if any build cache already exists for this key
|
||||
if to_remote.exists(key) and first(to_remote.walk_files(key)):
|
||||
if to_remote.fs.exists(key) and first(
|
||||
to_remote.fs.walk_files(key)
|
||||
):
|
||||
continue
|
||||
func(src, dst)
|
||||
ret.append((src.parent.name, src.name))
|
||||
@@ -233,8 +236,8 @@ class StageCache:
|
||||
remote = self.repo.cloud.get_remote(remote)
|
||||
return self._transfer(
|
||||
_log_exceptions(remote.fs.upload, "upload"),
|
||||
self.repo.odb.local.fs,
|
||||
remote.fs,
|
||||
self.repo.odb.local,
|
||||
remote.odb,
|
||||
)
|
||||
|
||||
def pull(self, remote):
|
||||
@@ -243,19 +246,20 @@ class StageCache:
|
||||
remote = self.repo.cloud.get_remote(remote)
|
||||
return self._transfer(
|
||||
_log_exceptions(remote.fs.download, "download"),
|
||||
remote.fs,
|
||||
self.repo.odb.local.fs,
|
||||
remote.odb,
|
||||
self.repo.odb.local,
|
||||
)
|
||||
|
||||
def get_used_cache(self, used_run_cache, *args, **kwargs):
|
||||
from dvc.objects.db import NamedCache
|
||||
|
||||
cache = NamedCache()
|
||||
def get_used_objs(self, used_run_cache, *args, **kwargs):
|
||||
"""Return used cache for the specified run-cached stages."""
|
||||
from collections import defaultdict
|
||||
|
||||
used_objs = defaultdict(set)
|
||||
for key, value in used_run_cache:
|
||||
entry = self._load_cache(key, value)
|
||||
if not entry:
|
||||
continue
|
||||
stage = self._create_stage(entry)
|
||||
cache.update(stage.get_used_cache(*args, **kwargs))
|
||||
return cache
|
||||
for odb, objs in stage.get_used_objs(*args, **kwargs).items():
|
||||
used_objs[odb].update(objs)
|
||||
return used_objs
|
||||
|
||||
@@ -75,6 +75,7 @@ class CheckpointTask(MonitorTask):
|
||||
def _run_callback(stage, callback_func):
|
||||
stage.save(allow_missing=True)
|
||||
stage.commit(allow_missing=True)
|
||||
stage.unprotect_outs()
|
||||
logger.debug("Running checkpoint callback for stage '%s'", stage)
|
||||
callback_func()
|
||||
|
||||
|
||||
+26
-11
@@ -1,5 +1,4 @@
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
@@ -7,20 +6,23 @@ from typing import (
|
||||
Iterable,
|
||||
Optional,
|
||||
Sequence,
|
||||
TextIO,
|
||||
Union,
|
||||
)
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.utils import colorize
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rich.status import Status
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.ui.table import Headers, Styles, TableData
|
||||
|
||||
|
||||
class Formatter:
|
||||
def __init__(self, theme: Dict = None, defaults: Dict = None) -> None:
|
||||
from collections import defaultdict
|
||||
|
||||
theme = theme or {
|
||||
"success": {"color": "green", "style": "bold"},
|
||||
"warn": {"color": "yellow"},
|
||||
@@ -29,6 +31,8 @@ class Formatter:
|
||||
self.theme = defaultdict(lambda: defaults or {}, theme)
|
||||
|
||||
def format(self, message: str, style: str = None, **kwargs) -> str:
|
||||
from dvc.utils import colorize
|
||||
|
||||
return colorize(message, **self.theme[style])
|
||||
|
||||
|
||||
@@ -75,22 +79,30 @@ class Console:
|
||||
stderr: bool = False,
|
||||
force: bool = False,
|
||||
styled: bool = False,
|
||||
file: TextIO = None,
|
||||
) -> None:
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
sep = " " if sep is None else sep
|
||||
end = "\n" if end is None else end
|
||||
if not self._enabled and not force:
|
||||
return
|
||||
|
||||
if styled:
|
||||
console = self.error_console if stderr else self.rich_console
|
||||
return console.print(*objects, sep=sep, end=end)
|
||||
file = file or (sys.stderr if stderr else sys.stdout)
|
||||
with Tqdm.external_write_mode(file=file):
|
||||
if styled:
|
||||
console = self.error_console if stderr else self.rich_console
|
||||
return console.print(*objects, sep=sep, end=end)
|
||||
|
||||
file = sys.stderr if stderr else sys.stdout
|
||||
values = (self.formatter.format(obj, style=style) for obj in objects)
|
||||
return print(*values, sep=sep, end=end, file=file)
|
||||
values = (
|
||||
self.formatter.format(obj, style=style) for obj in objects
|
||||
)
|
||||
return print(*values, sep=sep, end=end, file=file)
|
||||
|
||||
@staticmethod
|
||||
def progress(*args, **kwargs) -> Tqdm:
|
||||
def progress(*args, **kwargs) -> "Tqdm":
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
return Tqdm(*args, **kwargs)
|
||||
|
||||
def prompt(
|
||||
@@ -176,6 +188,9 @@ class Console:
|
||||
self, data, headers, markdown=markdown, pager=pager, force=force
|
||||
)
|
||||
|
||||
def status(self, status: str, **kwargs: Any) -> "Status":
|
||||
return self.error_console.status(status, **kwargs)
|
||||
|
||||
|
||||
ui = Console()
|
||||
|
||||
|
||||
+13
-4
@@ -319,10 +319,19 @@ def relpath(path, start=os.curdir):
|
||||
start = os.path.abspath(os.fspath(start))
|
||||
|
||||
# Windows path on different drive than curdir doesn't have relpath
|
||||
if os.name == "nt" and not os.path.commonprefix(
|
||||
[start, os.path.abspath(path)]
|
||||
):
|
||||
return path
|
||||
if os.name == "nt":
|
||||
# Since python 3.8 os.realpath resolves network shares to their UNC
|
||||
# path. So, to be certain that relative paths correctly captured,
|
||||
# we need to resolve to UNC path first. We resolve only the drive
|
||||
# name so that we don't follow any 'real' symlinks on the path
|
||||
def resolve_network_drive_windows(path_to_resolve):
|
||||
drive, tail = os.path.splitdrive(path_to_resolve)
|
||||
return os.path.join(os.path.realpath(drive), tail)
|
||||
|
||||
path = resolve_network_drive_windows(os.path.abspath(path))
|
||||
start = resolve_network_drive_windows(start)
|
||||
if not os.path.commonprefix([start, path]):
|
||||
return path
|
||||
return os.path.relpath(path, start)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import inspect
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from typing import Dict, Iterable, List, TypeVar, Union
|
||||
from functools import wraps
|
||||
from typing import Callable, Dict, Iterable, List, TypeVar, Union
|
||||
|
||||
from pygtrie import StringTrie as _StringTrie
|
||||
|
||||
@@ -76,3 +78,59 @@ def chunk_dict(d: Dict[_KT, _VT], size: int = 1) -> List[Dict[_KT, _VT]]:
|
||||
from funcy import chunks
|
||||
|
||||
return [{key: d[key] for key in chunk} for chunk in chunks(size, d)]
|
||||
|
||||
|
||||
class _NamespacedDict(dict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.__dict__ = self
|
||||
|
||||
|
||||
def validate(*validators: Callable, post: bool = False):
|
||||
"""
|
||||
Validate and transform arguments and results from function calls.
|
||||
|
||||
The validators functions are passed a dictionary of arguments, which
|
||||
supports dot notation access too.
|
||||
|
||||
The key is derived from the function signature, and hence is the name of
|
||||
the argument, whereas the value is the one passed to the function
|
||||
(if it is not passed, default value from keyword arguments are provided).
|
||||
|
||||
>>> def validator(args):
|
||||
... assert args["l"] >= 0 and args.b >= 0 and args.h >= 0
|
||||
|
||||
>>> @validate(validator)
|
||||
... def cuboid_area(l, b, h=1):
|
||||
... return 2*(l*b + l*h + b*h)
|
||||
|
||||
>>> cuboid_area(5, 20)
|
||||
250
|
||||
>>> cuboid_area(-1, -2)
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
AssertionError
|
||||
"""
|
||||
|
||||
def wrapped(func: Callable):
|
||||
sig = inspect.signature(func)
|
||||
|
||||
@wraps(func)
|
||||
def inner(*args, **kwargs):
|
||||
ba = sig.bind(*args, **kwargs)
|
||||
ba.apply_defaults()
|
||||
ba.arguments = _NamespacedDict(ba.arguments)
|
||||
|
||||
if not post:
|
||||
for validator in validators:
|
||||
validator(ba.arguments)
|
||||
|
||||
result = func(*ba.args, **ba.kwargs)
|
||||
if post:
|
||||
for validator in validators:
|
||||
result = validator(result)
|
||||
return result
|
||||
|
||||
return inner
|
||||
|
||||
return wrapped
|
||||
|
||||
+10
-6
@@ -4,11 +4,15 @@ import os
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.system import System
|
||||
from dvc.utils import dict_md5
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.types import StrPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LOCAL_CHUNK_SIZE = 2 ** 20 # 1 MB
|
||||
@@ -73,9 +77,9 @@ class BasePathNotInCheckedPathException(DvcException):
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
def contains_symlink_up_to(path, base_path):
|
||||
base_path = os.fspath(base_path)
|
||||
path = os.fspath(path)
|
||||
def contains_symlink_up_to(path: "StrPath", base_path: "StrPath"):
|
||||
base_path = os.path.normcase(os.fspath(base_path))
|
||||
path = os.path.normcase(os.fspath(path))
|
||||
|
||||
if base_path not in path:
|
||||
raise BasePathNotInCheckedPathException(path, base_path)
|
||||
@@ -144,11 +148,11 @@ def remove(path):
|
||||
raise
|
||||
|
||||
|
||||
def path_isin(child, parent):
|
||||
def path_isin(child: "StrPath", parent: "StrPath"):
|
||||
"""Check if given `child` path is inside `parent`."""
|
||||
|
||||
def normalize_path(path):
|
||||
return os.path.normpath(path)
|
||||
def normalize_path(path) -> str:
|
||||
return os.path.normcase(os.path.normpath(path))
|
||||
|
||||
parent = os.path.join(normalize_path(parent), "")
|
||||
child = normalize_path(child)
|
||||
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Any, Callable, ContextManager, Dict, Union
|
||||
|
||||
from funcy import reraise
|
||||
from typing_extensions import Protocol
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
@@ -42,13 +43,24 @@ class ParseError(DvcException):
|
||||
from dvc.utils import relpath
|
||||
|
||||
path = relpath(path)
|
||||
self.path = path
|
||||
super().__init__(f"unable to read: '{path}', {message}")
|
||||
|
||||
|
||||
class EncodingError(ParseError):
|
||||
"""We could not read a file with the given encoding"""
|
||||
|
||||
def __init__(self, path: "AnyPath", encoding: str):
|
||||
self.encoding = encoding
|
||||
super().__init__(path, f"is not valid {encoding}")
|
||||
|
||||
|
||||
def _load_data(path: "AnyPath", parser: ParserFn, fs: "BaseFileSystem" = None):
|
||||
open_fn = fs.open if fs else open
|
||||
with open_fn(path, encoding="utf-8") as fd: # type: ignore
|
||||
return parser(fd.read(), path)
|
||||
encoding = "utf-8"
|
||||
with open_fn(path, encoding=encoding) as fd: # type: ignore
|
||||
with reraise(UnicodeDecodeError, EncodingError(path, encoding)):
|
||||
return parser(fd.read(), path)
|
||||
|
||||
|
||||
def _dump_data(path, data: Any, dumper: DumperFn, fs: "BaseFileSystem" = None):
|
||||
|
||||
+5
-1
@@ -70,6 +70,7 @@ class HashedStreamReader(io.IOBase):
|
||||
def __init__(self, fobj):
|
||||
self.fobj = fobj
|
||||
self.md5 = hashlib.md5()
|
||||
self.total_read = 0
|
||||
self.is_text_file = None
|
||||
super().__init__()
|
||||
|
||||
@@ -95,9 +96,12 @@ class HashedStreamReader(io.IOBase):
|
||||
else:
|
||||
data = chunk
|
||||
self.md5.update(data)
|
||||
self.total_read += len(data)
|
||||
|
||||
return chunk
|
||||
|
||||
@property
|
||||
def hash_info(self):
|
||||
return HashInfo(self.PARAM_CHECKSUM, self.md5.hexdigest(), nfiles=1)
|
||||
return HashInfo(
|
||||
self.PARAM_CHECKSUM, self.md5.hexdigest(), size=self.total_read
|
||||
)
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
_BASE_VERSION = "2.2.0"
|
||||
_BASE_VERSION = "2.5.2"
|
||||
|
||||
|
||||
def _generate_version(base_version):
|
||||
|
||||
@@ -34,7 +34,7 @@ Source: "{#MyAppDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs c
|
||||
|
||||
[Tasks]
|
||||
Name: modifypath; Description: Adds dvc's application directory to environmental path; Flags: checkablealone;
|
||||
Name: modifypath\system; Description: Adds dvc's application directory to enviromental path for all users;
|
||||
Name: modifypath\system; Description: Adds dvc's application directory to environmental path for all users;
|
||||
Name: addsymlinkpermissions; Description: Add permission for creating symbolic links; Flags: checkablealone;
|
||||
Name: addsymlinkpermissions\system; Description: Add permissions for creating symbolic links for all users;
|
||||
|
||||
|
||||
@@ -73,14 +73,15 @@ install_requires = [
|
||||
"flufl.lock>=3.2,<4",
|
||||
"win-unicode-console>=0.5; sys_platform == 'win32'",
|
||||
"pywin32>=225; sys_platform == 'win32'",
|
||||
"networkx>=2.1",
|
||||
"networkx~=2.5",
|
||||
"psutil>=5.8.0",
|
||||
"pydot>=1.2.4",
|
||||
"speedcopy>=2.0.1; python_version < '3.8' and sys_platform == 'win32'",
|
||||
"dataclasses==0.7; python_version < '3.7'",
|
||||
"importlib-metadata==1.4; python_version < '3.8'",
|
||||
"flatten_dict>=0.3.0,<1",
|
||||
"tabulate>=0.8.7",
|
||||
"pygtrie==2.3.2",
|
||||
"pygtrie>=2.3.2",
|
||||
"dpath>=2.0.1,<3",
|
||||
"shtab>=1.3.4,<2",
|
||||
"rich>=10.0.0",
|
||||
@@ -88,16 +89,16 @@ install_requires = [
|
||||
"python-benedict>=0.21.1",
|
||||
"pyparsing==2.4.7",
|
||||
"typing_extensions>=3.7.4",
|
||||
"fsspec==2021.5.0",
|
||||
"fsspec>=2021.6.1",
|
||||
"diskcache>=5.2.1",
|
||||
]
|
||||
|
||||
|
||||
# Extra dependencies for remote integrations
|
||||
|
||||
gs = ["gcsfs==2021.5.0"]
|
||||
gs = ["gcsfs==2021.6.1"]
|
||||
gdrive = ["pydrive2>=1.8.1", "six >= 1.13.0"]
|
||||
s3 = ["s3fs==2021.5.0", "aiobotocore[boto3]==1.3.0"]
|
||||
s3 = ["s3fs==2021.6.1", "aiobotocore[boto3]==1.3.0"]
|
||||
azure = ["adlfs==0.7.1", "azure-identity>=1.4.0", "knack"]
|
||||
# https://github.com/Legrandin/pycryptodome/issues/465
|
||||
oss = ["oss2==2.6.1", "pycryptodome>=3.10"]
|
||||
@@ -106,7 +107,7 @@ ssh = ["paramiko[invoke]>=2.7.0"]
|
||||
# Remove the env marker if/when pyarrow is available for Python3.9
|
||||
hdfs = ["pyarrow>=2.0.0"]
|
||||
webhdfs = ["hdfs==2.5.8"]
|
||||
webdav = ["webdavclient3>=3.14.5"]
|
||||
webdav = ["webdav4>=0.8.1"]
|
||||
# gssapi should not be included in all_remotes, because it doesn't have wheels
|
||||
# for linux and mac, so it will fail to compile if user doesn't have all the
|
||||
# requirements, including kerberos itself. Once all the wheels are available,
|
||||
|
||||
+12
-8
@@ -2,12 +2,12 @@ wheel==0.36.2
|
||||
|
||||
# Test requirements
|
||||
pytest==6.2.4
|
||||
pytest-cov==2.12.0
|
||||
pytest-xdist==2.2.1
|
||||
pytest-cov==2.12.1
|
||||
pytest-xdist==2.3.0
|
||||
pytest-mock==3.6.1
|
||||
pytest-lazy-fixture==0.6.3
|
||||
pytest-timeout==1.4.2
|
||||
pytest-docker==0.10.1
|
||||
pytest-docker==0.10.3
|
||||
|
||||
flaky==3.7.0
|
||||
mock==4.0.3
|
||||
@@ -17,12 +17,12 @@ beautifulsoup4==4.9.3
|
||||
wget==3.2
|
||||
filelock==3.0.12
|
||||
wsgidav==3.1.0
|
||||
crc32c==2.2
|
||||
crc32c==2.2.post0
|
||||
xmltodict==0.12.0
|
||||
google-compute-engine==2.8.13
|
||||
google-cloud-storage==1.38.0
|
||||
google-cloud-storage==1.40.0
|
||||
|
||||
urllib3==1.26.5
|
||||
urllib3==1.26.6
|
||||
|
||||
# required by collective.checkdocs
|
||||
Pygments==2.9.0
|
||||
@@ -31,7 +31,7 @@ pydocstyle==6.1.1
|
||||
jaraco.windows==5.5.0
|
||||
|
||||
# pylint requirements
|
||||
pylint==2.8.2
|
||||
pylint==2.8.3
|
||||
# we use this to suppress pytest-related false positives in our tests.
|
||||
pylint-pytest==1.0.3
|
||||
# we use this to suppress some messages in tests, eg: foo/bar naming,
|
||||
@@ -39,7 +39,11 @@ pylint-pytest==1.0.3
|
||||
pylint-plugin-utils==0.6
|
||||
|
||||
# type-checking
|
||||
mypy==0.812
|
||||
mypy==0.910
|
||||
types-requests
|
||||
types-paramiko
|
||||
types-tabulate
|
||||
types-toml
|
||||
|
||||
# pypi doesn't allow for direct dependencies
|
||||
# gdrivefs @ git+https://github.com/intake/gdrivefs.git
|
||||
|
||||
@@ -7,9 +7,13 @@ from dvc.repo.experiments.base import EXEC_APPLY, EXEC_CHECKPOINT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("workspace", [True, False])
|
||||
@pytest.mark.parametrize("links", ["reflink,copy", "hardlink,symlink"])
|
||||
def test_new_checkpoint(
|
||||
tmp_dir, scm, dvc, checkpoint_stage, mocker, workspace
|
||||
tmp_dir, scm, dvc, checkpoint_stage, mocker, workspace, links
|
||||
):
|
||||
with dvc.config.edit() as conf:
|
||||
conf["cache"]["type"] = links
|
||||
|
||||
new_mock = mocker.spy(dvc.experiments, "new")
|
||||
results = dvc.experiments.run(
|
||||
checkpoint_stage.addressing, params=["foo=2"], tmp_dir=not workspace
|
||||
|
||||
@@ -571,7 +571,7 @@ def test_raise_on_wrong_field(tmp_dir, scm, dvc, run_copy_metrics):
|
||||
|
||||
|
||||
def test_load_metric_from_dict_json(tmp_dir):
|
||||
metric = [{"acccuracy": 1, "loss": 2}, {"accuracy": 3, "loss": 4}]
|
||||
metric = [{"accuracy": 1, "loss": 2}, {"accuracy": 3, "loss": 4}]
|
||||
dmetric = {"train": metric}
|
||||
|
||||
plot_data = JSONPlotData("-", "revision", json.dumps(dmetric))
|
||||
@@ -584,7 +584,7 @@ def test_load_metric_from_dict_json(tmp_dir):
|
||||
|
||||
|
||||
def test_load_metric_from_dict_yaml(tmp_dir):
|
||||
metric = [{"acccuracy": 1, "loss": 2}, {"accuracy": 3, "loss": 4}]
|
||||
metric = [{"accuracy": 1, "loss": 2}, {"accuracy": 3, "loss": 4}]
|
||||
dmetric = {"train": metric}
|
||||
|
||||
plot_data = YAMLPlotData("-", "revision", dumps_yaml(dmetric))
|
||||
@@ -683,17 +683,18 @@ def test_show_no_repo(tmp_dir):
|
||||
dvc.plots.show(["metric.json"])
|
||||
|
||||
|
||||
def test_show_from_subdir(tmp_dir, dvc, caplog):
|
||||
def test_show_from_subdir(tmp_dir, dvc, capsys):
|
||||
subdir = tmp_dir / "subdir"
|
||||
|
||||
subdir.mkdir()
|
||||
metric = [{"first_val": 100, "val": 2}, {"first_val": 200, "val": 3}]
|
||||
_write_json(subdir, metric, "metric.json")
|
||||
|
||||
with subdir.chdir(), caplog.at_level(logging.INFO, "dvc"):
|
||||
with subdir.chdir():
|
||||
assert main(["plots", "show", "metric.json"]) == 0
|
||||
|
||||
assert subdir.as_uri() in caplog.text
|
||||
out, _ = capsys.readouterr()
|
||||
assert subdir.as_uri() in out
|
||||
assert (subdir / "plots.html").exists()
|
||||
|
||||
|
||||
|
||||
+60
-18
@@ -1,6 +1,5 @@
|
||||
import errno
|
||||
import filecmp
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
@@ -24,7 +23,11 @@ from dvc.fs.local import LocalFileSystem
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.main import main
|
||||
from dvc.objects.db import ODBManager
|
||||
from dvc.output import OutputAlreadyTrackedError, OutputIsStageFileError
|
||||
from dvc.output import (
|
||||
OutputAlreadyTrackedError,
|
||||
OutputDoesNotExistError,
|
||||
OutputIsStageFileError,
|
||||
)
|
||||
from dvc.stage import Stage
|
||||
from dvc.stage.exceptions import (
|
||||
StageExternalOutputsError,
|
||||
@@ -127,14 +130,12 @@ class TestAddCmdDirectoryRecursive(TestDvc):
|
||||
|
||||
def test_warn_about_large_directories(self):
|
||||
warning = (
|
||||
"You are adding a large directory 'large-dir' recursively,"
|
||||
" consider tracking it as a whole instead.\n"
|
||||
"{purple}HINT:{nc} Remove the generated DVC file and then"
|
||||
" run `{cyan}dvc add large-dir{nc}`".format(
|
||||
purple=colorama.Fore.MAGENTA,
|
||||
cyan=colorama.Fore.CYAN,
|
||||
nc=colorama.Style.RESET_ALL,
|
||||
)
|
||||
"You are adding a large directory 'large-dir' recursively."
|
||||
"\nConsider tracking it as a whole instead with "
|
||||
"`{cyan}dvc add large-dir{nc}`"
|
||||
).format(
|
||||
cyan=colorama.Fore.CYAN,
|
||||
nc=colorama.Style.RESET_ALL,
|
||||
)
|
||||
|
||||
os.mkdir("large-dir")
|
||||
@@ -145,9 +146,8 @@ class TestAddCmdDirectoryRecursive(TestDvc):
|
||||
with open(path, "w") as fobj:
|
||||
fobj.write(path)
|
||||
|
||||
with self._caplog.at_level(logging.WARNING, logger="dvc"):
|
||||
assert main(["add", "--recursive", "large-dir"]) == 0
|
||||
assert warning in self._caplog.messages
|
||||
assert main(["add", "--recursive", "large-dir"]) == 0
|
||||
assert warning in self._capsys.readouterr()[1]
|
||||
|
||||
|
||||
class TestAddDirectoryWithForwardSlash(TestDvc):
|
||||
@@ -639,7 +639,7 @@ def test_failed_add_cleanup(tmp_dir, scm, dvc):
|
||||
|
||||
|
||||
def test_should_not_track_git_internal_files(mocker, dvc, tmp_dir):
|
||||
stage_creator_spy = mocker.spy(dvc_module.repo.add, "_create_stages")
|
||||
stage_creator_spy = mocker.spy(dvc_module.repo.add, "create_stages")
|
||||
|
||||
ret = main(["add", "-R", dvc.root_dir])
|
||||
assert ret == 0
|
||||
@@ -947,15 +947,15 @@ def test_add_file_in_symlink_dir(make_tmp_dir, tmp_dir, dvc, external):
|
||||
dvc.add(os.path.join("dir", "foo"))
|
||||
|
||||
|
||||
def test_add_with_cache_link_error(tmp_dir, dvc, mocker, caplog):
|
||||
def test_add_with_cache_link_error(tmp_dir, dvc, mocker, capsys):
|
||||
tmp_dir.gen("foo", "foo")
|
||||
|
||||
mocker.patch(
|
||||
"dvc.checkout._do_link", side_effect=DvcException("link failed")
|
||||
)
|
||||
with caplog.at_level(logging.WARNING, logger="dvc"):
|
||||
dvc.add("foo")
|
||||
assert "reconfigure cache types" in caplog.text
|
||||
dvc.add("foo")
|
||||
err = capsys.readouterr()[1]
|
||||
assert "reconfigure cache types" in err
|
||||
|
||||
assert not (tmp_dir / "foo").exists()
|
||||
assert (tmp_dir / "foo.dvc").exists()
|
||||
@@ -1024,6 +1024,7 @@ def test_add_to_remote(tmp_dir, dvc, local_cloud, local_remote):
|
||||
|
||||
hash_info = stage.outs[0].hash_info
|
||||
assert local_remote.hash_to_path_info(hash_info.value).read_text() == "foo"
|
||||
assert hash_info.size == len("foo")
|
||||
|
||||
|
||||
def test_add_to_remote_absolute(tmp_dir, make_tmp_dir, dvc, local_remote):
|
||||
@@ -1067,6 +1068,8 @@ def test_add_to_cache_dir(tmp_dir, dvc, local_cloud):
|
||||
(stage,) = dvc.add(str(local_cloud / "data"), out="data")
|
||||
assert len(stage.deps) == 0
|
||||
assert len(stage.outs) == 1
|
||||
assert stage.outs[0].hash_info.size == len("foo") + len("bar")
|
||||
assert stage.outs[0].hash_info.nfiles == 2
|
||||
|
||||
data = tmp_dir / "data"
|
||||
assert data.read_text() == {"foo": "foo", "bar": "bar"}
|
||||
@@ -1187,3 +1190,42 @@ def test_add_ignored(tmp_dir, scm, dvc):
|
||||
assert str(exc.value) == ("bad DVC file name '{}' is git-ignored.").format(
|
||||
os.path.join("dir", "subdir.dvc")
|
||||
)
|
||||
|
||||
|
||||
def test_add_on_not_existing_file_should_not_remove_stage_file(tmp_dir, dvc):
|
||||
(stage,) = tmp_dir.dvc_gen("foo", "foo")
|
||||
(tmp_dir / "foo").unlink()
|
||||
dvcfile_contents = (tmp_dir / stage.path).read_text()
|
||||
|
||||
with pytest.raises(OutputDoesNotExistError):
|
||||
dvc.add("foo")
|
||||
assert (tmp_dir / "foo.dvc").exists()
|
||||
assert (tmp_dir / stage.path).read_text() == dvcfile_contents
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
[
|
||||
"dvc.repo.Repo.check_modified_graph",
|
||||
"dvc.stage.Stage.save",
|
||||
"dvc.stage.Stage.commit",
|
||||
],
|
||||
)
|
||||
def test_add_does_not_remove_stage_file_on_failure(
|
||||
tmp_dir, dvc, mocker, target
|
||||
):
|
||||
(stage,) = tmp_dir.dvc_gen("foo", "foo")
|
||||
tmp_dir.gen("foo", "foobar") # update file
|
||||
dvcfile_contents = (tmp_dir / stage.path).read_text()
|
||||
|
||||
exc_msg = f"raising error from mocked '{target}'"
|
||||
mocker.patch(
|
||||
target,
|
||||
side_effect=DvcException(exc_msg),
|
||||
)
|
||||
|
||||
with pytest.raises(DvcException) as exc_info:
|
||||
dvc.add("foo")
|
||||
assert str(exc_info.value) == exc_msg
|
||||
assert (tmp_dir / "foo.dvc").exists()
|
||||
assert (tmp_dir / stage.path).read_text() == dvcfile_contents
|
||||
|
||||
+30
-29
@@ -98,7 +98,7 @@ def test_config_set_local(tmp_dir, dvc):
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_config_get(tmp_dir, dvc, caplog, args, ret, msg):
|
||||
def test_config_get(tmp_dir, dvc, capsys, caplog, args, ret, msg):
|
||||
(tmp_dir / ".dvc" / "config").write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
@@ -123,12 +123,12 @@ def test_config_get(tmp_dir, dvc, caplog, args, ret, msg):
|
||||
)
|
||||
)
|
||||
|
||||
caplog.clear()
|
||||
assert main(["config"] + args) == ret
|
||||
assert msg in caplog.text
|
||||
text = caplog.text if ret else capsys.readouterr()[0]
|
||||
assert msg in text
|
||||
|
||||
|
||||
def test_config_list(tmp_dir, dvc, caplog):
|
||||
def test_config_list(tmp_dir, dvc, capsys):
|
||||
(tmp_dir / ".dvc" / "config").write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
@@ -155,17 +155,18 @@ def test_config_list(tmp_dir, dvc, caplog):
|
||||
)
|
||||
)
|
||||
|
||||
caplog.clear()
|
||||
assert main(["config", "--list"]) == 0
|
||||
assert "remote.myremote.url=s3://bucket/path" in caplog.text
|
||||
assert "remote.myremote.region=us-east-2" in caplog.text
|
||||
assert "remote.myremote.profile=iterative" in caplog.text
|
||||
assert "remote.myremote.access_key_id=abcde" in caplog.text
|
||||
assert "remote.myremote.secret_access_key=123456" in caplog.text
|
||||
assert "remote.other.url=gs://bucket/path" in caplog.text
|
||||
assert "core.analytics=False" in caplog.text
|
||||
assert "core.no_scm=true" in caplog.text
|
||||
assert "core.remote=myremote" in caplog.text
|
||||
|
||||
out, _ = capsys.readouterr()
|
||||
assert "remote.myremote.url=s3://bucket/path" in out
|
||||
assert "remote.myremote.region=us-east-2" in out
|
||||
assert "remote.myremote.profile=iterative" in out
|
||||
assert "remote.myremote.access_key_id=abcde" in out
|
||||
assert "remote.myremote.secret_access_key=123456" in out
|
||||
assert "remote.other.url=gs://bucket/path" in out
|
||||
assert "core.analytics=False" in out
|
||||
assert "core.no_scm=true" in out
|
||||
assert "core.remote=myremote" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -241,57 +242,56 @@ def test_load_relative_paths(dvc, field, remote_url):
|
||||
)
|
||||
|
||||
|
||||
def test_config_remote(tmp_dir, dvc, caplog):
|
||||
def test_config_remote(tmp_dir, dvc, capsys):
|
||||
(tmp_dir / ".dvc" / "config").write_text(
|
||||
"['remote \"myremote\"']\n"
|
||||
" url = s3://bucket/path\n"
|
||||
" region = myregion\n"
|
||||
)
|
||||
|
||||
caplog.clear()
|
||||
assert main(["config", "remote.myremote.url"]) == 0
|
||||
assert "s3://bucket/path" in caplog.text
|
||||
out, _ = capsys.readouterr()
|
||||
assert "s3://bucket/path" in out
|
||||
|
||||
caplog.clear()
|
||||
assert main(["config", "remote.myremote.region"]) == 0
|
||||
assert "myregion" in caplog.text
|
||||
out, _ = capsys.readouterr()
|
||||
assert "myregion" in out
|
||||
|
||||
|
||||
def test_config_show_origin_single(tmp_dir, dvc, caplog):
|
||||
def test_config_show_origin_single(tmp_dir, dvc, capsys):
|
||||
(tmp_dir / ".dvc" / "config").write_text(
|
||||
"['remote \"myremote\"']\n"
|
||||
" url = s3://bucket/path\n"
|
||||
" region = myregion\n"
|
||||
)
|
||||
|
||||
caplog.clear()
|
||||
assert (
|
||||
main(["config", "--show-origin", "--project", "remote.myremote.url"])
|
||||
== 0
|
||||
)
|
||||
out, _ = capsys.readouterr()
|
||||
assert (
|
||||
"{}\t{}\n".format(os.path.join(".dvc", "config"), "s3://bucket/path")
|
||||
in caplog.text
|
||||
in out
|
||||
)
|
||||
|
||||
caplog.clear()
|
||||
assert (
|
||||
main(["config", "--show-origin", "--local", "remote.myremote.url"])
|
||||
== 251
|
||||
)
|
||||
|
||||
caplog.clear()
|
||||
assert main(["config", "--list", "--project", "--show-origin"]) == 0
|
||||
out, _ = capsys.readouterr()
|
||||
assert (
|
||||
"{}\t{}\n".format(
|
||||
os.path.join(".dvc", "config"),
|
||||
"remote.myremote.url=s3://bucket/path",
|
||||
)
|
||||
in caplog.text
|
||||
in out
|
||||
)
|
||||
|
||||
|
||||
def test_config_show_origin_merged(tmp_dir, dvc, caplog):
|
||||
def test_config_show_origin_merged(tmp_dir, dvc, capsys):
|
||||
(tmp_dir / ".dvc" / "config").write_text(
|
||||
"['remote \"myremote\"']\n"
|
||||
" url = s3://bucket/path\n"
|
||||
@@ -302,18 +302,19 @@ def test_config_show_origin_merged(tmp_dir, dvc, caplog):
|
||||
"['remote \"myremote\"']\n timeout = 100\n"
|
||||
)
|
||||
|
||||
caplog.clear()
|
||||
assert main(["config", "--list", "--show-origin"]) == 0
|
||||
out, _ = capsys.readouterr()
|
||||
assert (
|
||||
"{}\t{}\n".format(
|
||||
os.path.join(".dvc", "config"),
|
||||
"remote.myremote.url=s3://bucket/path",
|
||||
)
|
||||
in caplog.text
|
||||
in out
|
||||
)
|
||||
|
||||
assert (
|
||||
"{}\t{}\n".format(
|
||||
os.path.join(".dvc", "config.local"), "remote.myremote.timeout=100"
|
||||
)
|
||||
in caplog.text
|
||||
in out
|
||||
)
|
||||
|
||||
+104
-44
@@ -8,7 +8,6 @@ from flaky.flaky_decorator import flaky
|
||||
import dvc as dvc_module
|
||||
from dvc.external_repo import clean_repos
|
||||
from dvc.main import main
|
||||
from dvc.objects.db import NamedCache
|
||||
from dvc.remote.base import (
|
||||
STATUS_DELETED,
|
||||
STATUS_MISSING,
|
||||
@@ -41,6 +40,14 @@ all_clouds = [
|
||||
)
|
||||
]
|
||||
|
||||
# Clouds that implement the general methods that can be tested
|
||||
# for functional tests that require extensive apis (e.g traversing
|
||||
# via walk_files())
|
||||
full_clouds = [
|
||||
pytest.lazy_fixture(cloud)
|
||||
for cloud in ["s3", "gs", "azure", "ssh", "hdfs"]
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.needs_internet
|
||||
@pytest.mark.parametrize("remote", all_clouds, indirect=True)
|
||||
@@ -49,7 +56,7 @@ def test_cloud(tmp_dir, dvc, remote): # pylint:disable=unused-argument
|
||||
out = stage.outs[0]
|
||||
cache = out.cache_path
|
||||
md5 = out.hash_info.value
|
||||
info = out.get_used_cache()
|
||||
foo_objs = out.get_used_objs().get(None, set())
|
||||
|
||||
(stage_dir,) = tmp_dir.dvc_gen(
|
||||
{
|
||||
@@ -62,29 +69,39 @@ def test_cloud(tmp_dir, dvc, remote): # pylint:disable=unused-argument
|
||||
)
|
||||
out_dir = stage_dir.outs[0]
|
||||
cache_dir = out_dir.cache_path
|
||||
name_dir = str(out_dir)
|
||||
md5_dir = out_dir.hash_info.value
|
||||
info_dir = NamedCache.make(out_dir.scheme, md5_dir, name_dir)
|
||||
dir_objs = {out_dir.obj}
|
||||
entry_md5s = [entry_obj.hash_info.value for _, entry_obj in out_dir.obj]
|
||||
|
||||
def _make_dir_status(status):
|
||||
expected = {md5_dir: {"name": md5_dir, "status": status}}
|
||||
expected.update(
|
||||
{
|
||||
entry_md5: {"name": entry_md5, "status": status}
|
||||
for entry_md5 in entry_md5s
|
||||
}
|
||||
)
|
||||
return expected
|
||||
|
||||
# Check status
|
||||
status = dvc.cloud.status(info, show_checksums=True)
|
||||
status = dvc.cloud.status(foo_objs, show_checksums=True)
|
||||
expected = {md5: {"name": md5, "status": STATUS_NEW}}
|
||||
assert status == expected
|
||||
|
||||
status_dir = dvc.cloud.status(info_dir, show_checksums=True)
|
||||
expected = {md5_dir: {"name": md5_dir, "status": STATUS_NEW}}
|
||||
status_dir = dvc.cloud.status(dir_objs, show_checksums=True)
|
||||
expected = _make_dir_status(STATUS_NEW)
|
||||
assert status_dir == expected
|
||||
|
||||
# Move cache and check status
|
||||
# See issue https://github.com/iterative/dvc/issues/4383 for details
|
||||
backup_dir = dvc.odb.local.cache_dir + ".backup"
|
||||
move(dvc.odb.local.cache_dir, backup_dir)
|
||||
status = dvc.cloud.status(info, show_checksums=True)
|
||||
status = dvc.cloud.status(foo_objs, show_checksums=True)
|
||||
expected = {md5: {"name": md5, "status": STATUS_MISSING}}
|
||||
assert status == expected
|
||||
|
||||
status_dir = dvc.cloud.status(info_dir, show_checksums=True)
|
||||
expected = {md5_dir: {"name": md5_dir, "status": STATUS_MISSING}}
|
||||
status_dir = dvc.cloud.status(dir_objs, show_checksums=True)
|
||||
expected = _make_dir_status(STATUS_MISSING)
|
||||
assert status_dir == expected
|
||||
|
||||
# Restore original cache:
|
||||
@@ -92,48 +109,48 @@ def test_cloud(tmp_dir, dvc, remote): # pylint:disable=unused-argument
|
||||
move(backup_dir, dvc.odb.local.cache_dir)
|
||||
|
||||
# Push and check status
|
||||
dvc.cloud.push(info)
|
||||
dvc.cloud.push(foo_objs)
|
||||
assert os.path.exists(cache)
|
||||
assert os.path.isfile(cache)
|
||||
|
||||
dvc.cloud.push(info_dir)
|
||||
dvc.cloud.push(dir_objs)
|
||||
assert os.path.isfile(cache_dir)
|
||||
|
||||
status = dvc.cloud.status(info, show_checksums=True)
|
||||
status = dvc.cloud.status(foo_objs, show_checksums=True)
|
||||
expected = {md5: {"name": md5, "status": STATUS_OK}}
|
||||
assert status == expected
|
||||
|
||||
status_dir = dvc.cloud.status(info_dir, show_checksums=True)
|
||||
expected = {md5_dir: {"name": md5_dir, "status": STATUS_OK}}
|
||||
status_dir = dvc.cloud.status(dir_objs, show_checksums=True)
|
||||
expected = _make_dir_status(STATUS_OK)
|
||||
assert status_dir == expected
|
||||
|
||||
# Remove and check status
|
||||
remove(dvc.odb.local.cache_dir)
|
||||
|
||||
status = dvc.cloud.status(info, show_checksums=True)
|
||||
status = dvc.cloud.status(foo_objs, show_checksums=True)
|
||||
expected = {md5: {"name": md5, "status": STATUS_DELETED}}
|
||||
assert status == expected
|
||||
|
||||
status_dir = dvc.cloud.status(info_dir, show_checksums=True)
|
||||
expected = {md5_dir: {"name": md5_dir, "status": STATUS_DELETED}}
|
||||
status_dir = dvc.cloud.status(dir_objs, show_checksums=True)
|
||||
expected = _make_dir_status(STATUS_DELETED)
|
||||
assert status_dir == expected
|
||||
|
||||
# Pull and check status
|
||||
dvc.cloud.pull(info)
|
||||
dvc.cloud.pull(foo_objs)
|
||||
assert os.path.exists(cache)
|
||||
assert os.path.isfile(cache)
|
||||
with open(cache) as fd:
|
||||
assert fd.read() == "foo"
|
||||
|
||||
dvc.cloud.pull(info_dir)
|
||||
dvc.cloud.pull(dir_objs)
|
||||
assert os.path.isfile(cache_dir)
|
||||
|
||||
status = dvc.cloud.status(info, show_checksums=True)
|
||||
status = dvc.cloud.status(foo_objs, show_checksums=True)
|
||||
expected = {md5: {"name": md5, "status": STATUS_OK}}
|
||||
assert status == expected
|
||||
|
||||
status_dir = dvc.cloud.status(info_dir, show_checksums=True)
|
||||
expected = {md5_dir: {"name": md5_dir, "status": STATUS_OK}}
|
||||
status_dir = dvc.cloud.status(dir_objs, show_checksums=True)
|
||||
expected = _make_dir_status(STATUS_OK)
|
||||
assert status_dir == expected
|
||||
|
||||
|
||||
@@ -231,7 +248,7 @@ def test_warn_on_outdated_stage(tmp_dir, dvc, local_remote, caplog):
|
||||
|
||||
def test_hash_recalculation(mocker, dvc, tmp_dir, local_remote):
|
||||
tmp_dir.gen({"foo": "foo"})
|
||||
test_get_file_hash = mocker.spy(dvc_module.objects.stage, "_get_file_hash")
|
||||
test_file_md5 = mocker.spy(dvc_module.objects.stage, "file_md5")
|
||||
ret = main(["config", "cache.type", "hardlink"])
|
||||
assert ret == 0
|
||||
ret = main(["add", "foo"])
|
||||
@@ -240,7 +257,7 @@ def test_hash_recalculation(mocker, dvc, tmp_dir, local_remote):
|
||||
assert ret == 0
|
||||
ret = main(["run", "--single-stage", "-d", "foo", "echo foo"])
|
||||
assert ret == 0
|
||||
assert test_get_file_hash.mock.call_count == 1
|
||||
assert test_file_md5.mock.call_count == 1
|
||||
|
||||
|
||||
def test_missing_cache(tmp_dir, dvc, local_remote, caplog):
|
||||
@@ -269,7 +286,10 @@ def test_missing_cache(tmp_dir, dvc, local_remote, caplog):
|
||||
assert bar in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
assert dvc.status(cloud=True) == {"bar": "missing", "foo": "missing"}
|
||||
assert dvc.status(cloud=True) == {
|
||||
"bar": "missing",
|
||||
"foo": "missing",
|
||||
}
|
||||
assert header not in caplog.text
|
||||
assert foo not in caplog.text
|
||||
assert bar not in caplog.text
|
||||
@@ -287,7 +307,7 @@ def test_verify_hashes(
|
||||
remove("dir")
|
||||
remove(dvc.odb.local.cache_dir)
|
||||
|
||||
hash_spy = mocker.spy(dvc_module.objects.stage, "_get_file_hash")
|
||||
hash_spy = mocker.spy(dvc_module.objects.stage, "file_md5")
|
||||
|
||||
dvc.pull()
|
||||
assert hash_spy.call_count == 0
|
||||
@@ -455,13 +475,12 @@ def test_pipeline_file_target_ops(tmp_dir, dvc, run_copy, local_remote):
|
||||
({}, "Everything is up to date"),
|
||||
],
|
||||
)
|
||||
def test_push_stats(tmp_dir, dvc, fs, msg, caplog, local_remote):
|
||||
def test_push_stats(tmp_dir, dvc, fs, msg, capsys, local_remote):
|
||||
tmp_dir.dvc_gen(fs)
|
||||
|
||||
caplog.clear()
|
||||
with caplog.at_level(level=logging.INFO, logger="dvc"):
|
||||
main(["push"])
|
||||
assert msg in caplog.text
|
||||
main(["push"])
|
||||
out, _ = capsys.readouterr()
|
||||
assert msg in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -472,17 +491,17 @@ def test_push_stats(tmp_dir, dvc, fs, msg, caplog, local_remote):
|
||||
({}, "Everything is up to date."),
|
||||
],
|
||||
)
|
||||
def test_fetch_stats(tmp_dir, dvc, fs, msg, caplog, local_remote):
|
||||
def test_fetch_stats(tmp_dir, dvc, fs, msg, capsys, local_remote):
|
||||
tmp_dir.dvc_gen(fs)
|
||||
dvc.push()
|
||||
clean(list(fs.keys()), dvc)
|
||||
caplog.clear()
|
||||
with caplog.at_level(level=logging.INFO, logger="dvc"):
|
||||
main(["fetch"])
|
||||
assert msg in caplog.text
|
||||
|
||||
main(["fetch"])
|
||||
out, _ = capsys.readouterr()
|
||||
assert msg in out
|
||||
|
||||
|
||||
def test_pull_stats(tmp_dir, dvc, capsys, caplog, local_remote):
|
||||
def test_pull_stats(tmp_dir, dvc, capsys, local_remote):
|
||||
tmp_dir.dvc_gen({"foo": "foo", "bar": "bar"})
|
||||
dvc.push()
|
||||
clean(["foo", "bar"], dvc)
|
||||
@@ -493,12 +512,13 @@ def test_pull_stats(tmp_dir, dvc, capsys, caplog, local_remote):
|
||||
out, _ = capsys.readouterr()
|
||||
assert "M\tbar".expandtabs() in out
|
||||
assert "A\tfoo".expandtabs() in out
|
||||
assert "2 files fetched" in caplog.text
|
||||
assert "1 file added" in caplog.text
|
||||
assert "1 file modified" in caplog.text
|
||||
with caplog.at_level(level=logging.INFO, logger="dvc"):
|
||||
main(["pull"])
|
||||
assert "Everything is up to date." in caplog.text
|
||||
assert "2 files fetched" in out
|
||||
assert "1 file added" in out
|
||||
assert "1 file modified" in out
|
||||
|
||||
main(["pull"])
|
||||
out, _ = capsys.readouterr()
|
||||
assert "Everything is up to date." in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -548,3 +568,43 @@ def test_pull_partial(tmp_dir, dvc, local_remote):
|
||||
stats = dvc.pull(os.path.join("foo", "bar"))
|
||||
assert stats["fetched"] == 1
|
||||
assert (tmp_dir / "foo").read_text() == {"bar": {"baz": "baz"}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("remote", full_clouds, indirect=True)
|
||||
def test_pull_00_prefix(tmp_dir, dvc, remote, monkeypatch):
|
||||
# Related: https://github.com/iterative/dvc/issues/6089
|
||||
|
||||
fs_type = type(dvc.cloud.get_remote("upstream").fs)
|
||||
monkeypatch.setattr(fs_type, "_ALWAYS_TRAVERSE", True, raising=False)
|
||||
monkeypatch.setattr(fs_type, "LIST_OBJECT_PAGE_SIZE", 256, raising=False)
|
||||
|
||||
# foo's md5 checksum is 00411460f7c92d2124a67ea0f4cb5f85
|
||||
# bar's md5 checksum is 0000000018e6137ac2caab16074784a6
|
||||
tmp_dir.dvc_gen({"foo": "363", "bar": "jk8ssl"})
|
||||
|
||||
dvc.push()
|
||||
clean(["foo", "bar"], dvc)
|
||||
|
||||
stats = dvc.pull()
|
||||
assert stats["fetched"] == 2
|
||||
assert set(stats["added"]) == {"foo", "bar"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("remote", full_clouds, indirect=True)
|
||||
def test_pull_no_00_prefix(tmp_dir, dvc, remote, monkeypatch):
|
||||
# Related: https://github.com/iterative/dvc/issues/6244
|
||||
|
||||
fs_type = type(dvc.cloud.get_remote("upstream").fs)
|
||||
monkeypatch.setattr(fs_type, "_ALWAYS_TRAVERSE", True, raising=False)
|
||||
monkeypatch.setattr(fs_type, "LIST_OBJECT_PAGE_SIZE", 256, raising=False)
|
||||
|
||||
# foo's md5 checksum is 14ffd92a6cbf5f2f657067df0d5881a6
|
||||
# bar's md5 checksum is 64020400f00960c0ef04052547b134b3
|
||||
tmp_dir.dvc_gen({"foo": "dvc", "bar": "cml"})
|
||||
|
||||
dvc.push()
|
||||
clean(["foo", "bar"], dvc)
|
||||
|
||||
stats = dvc.pull()
|
||||
assert stats["fetched"] == 2
|
||||
assert set(stats["added"]) == {"foo", "bar"}
|
||||
|
||||
+8
-11
@@ -244,9 +244,8 @@ def test_walk_dont_ignore_subrepos(tmp_dir, scm, dvc):
|
||||
)
|
||||
def test_fs_getsize(dvc, cloud):
|
||||
cloud.gen({"data": {"foo": "foo"}, "baz": "baz baz"})
|
||||
cls, config = get_cloud_fs(dvc, **cloud.config)
|
||||
cls, config, path_info = get_cloud_fs(dvc, **cloud.config)
|
||||
fs = cls(**config)
|
||||
path_info = fs.path_info
|
||||
|
||||
assert fs.getsize(path_info / "baz") == 7
|
||||
assert fs.getsize(path_info / "data" / "foo") == 3
|
||||
@@ -270,11 +269,11 @@ def test_fs_getsize(dvc, cloud):
|
||||
)
|
||||
def test_fs_upload_fobj(dvc, tmp_dir, cloud):
|
||||
tmp_dir.gen("foo", "foo")
|
||||
cls, config = get_cloud_fs(dvc, **cloud.config)
|
||||
cls, config, path_info = get_cloud_fs(dvc, **cloud.config)
|
||||
fs = cls(**config)
|
||||
|
||||
from_info = tmp_dir / "foo"
|
||||
to_info = fs.path_info / "foo"
|
||||
to_info = path_info / "foo"
|
||||
|
||||
with open(from_info, "rb") as stream:
|
||||
fs.upload_fobj(stream, to_info)
|
||||
@@ -297,9 +296,9 @@ def test_fs_ls(dvc, cloud):
|
||||
}
|
||||
}
|
||||
)
|
||||
cls, config = get_cloud_fs(dvc, **cloud.config)
|
||||
cls, config, path_info = get_cloud_fs(dvc, **cloud.config)
|
||||
fs = cls(**config)
|
||||
path_info = cloud / "directory"
|
||||
path_info /= "directory"
|
||||
|
||||
assert {os.path.basename(file_key) for file_key in fs.ls(path_info)} == {
|
||||
"foo",
|
||||
@@ -327,9 +326,8 @@ def test_fs_ls(dvc, cloud):
|
||||
)
|
||||
def test_fs_find_recursive(dvc, cloud):
|
||||
cloud.gen({"data": {"foo": "foo", "bar": {"baz": "baz"}, "quux": "quux"}})
|
||||
cls, config = get_cloud_fs(dvc, **cloud.config)
|
||||
cls, config, path_info = get_cloud_fs(dvc, **cloud.config)
|
||||
fs = cls(**config)
|
||||
path_info = fs.path_info
|
||||
|
||||
assert {
|
||||
os.path.basename(file_key) for file_key in fs.find(path_info / "data")
|
||||
@@ -349,9 +347,8 @@ def test_fs_find_recursive(dvc, cloud):
|
||||
)
|
||||
def test_fs_find_with_etag(dvc, cloud):
|
||||
cloud.gen({"data": {"foo": "foo", "bar": {"baz": "baz"}, "quux": "quux"}})
|
||||
cls, config = get_cloud_fs(dvc, **cloud.config)
|
||||
cls, config, path_info = get_cloud_fs(dvc, **cloud.config)
|
||||
fs = cls(**config)
|
||||
path_info = fs.path_info
|
||||
|
||||
for details in fs.find(path_info / "data", detail=True):
|
||||
assert (
|
||||
@@ -371,7 +368,7 @@ def test_fs_find_with_etag(dvc, cloud):
|
||||
)
|
||||
def test_fs_fsspec_path_management(dvc, cloud):
|
||||
cloud.gen({"foo": "foo", "data": {"bar": "bar", "baz": {"foo": "foo"}}})
|
||||
cls, config = get_cloud_fs(dvc, **cloud.config)
|
||||
cls, config, _ = get_cloud_fs(dvc, **cloud.config)
|
||||
fs = cls(**config)
|
||||
|
||||
root = cloud.parents[len(cloud.parents) - 1]
|
||||
|
||||
@@ -8,7 +8,7 @@ from mock import patch
|
||||
import dvc.data_cloud as cloud
|
||||
from dvc.config import NoRemoteError
|
||||
from dvc.dvcfile import Dvcfile
|
||||
from dvc.exceptions import DownloadError
|
||||
from dvc.exceptions import DownloadError, PathMissingError
|
||||
from dvc.objects.db import ODBManager
|
||||
from dvc.stage.exceptions import StagePathNotFoundError
|
||||
from dvc.system import System
|
||||
@@ -289,7 +289,7 @@ def test_push_wildcard_from_bare_git_repo(
|
||||
dvc_repo = make_tmp_dir("dvc-repo", scm=True, dvc=True)
|
||||
with dvc_repo.chdir():
|
||||
dvc_repo.dvc.imp(os.fspath(tmp_dir), "dirextra")
|
||||
with pytest.raises(FileNotFoundError):
|
||||
with pytest.raises(PathMissingError):
|
||||
dvc_repo.dvc.imp(os.fspath(tmp_dir), "dir123")
|
||||
|
||||
|
||||
@@ -347,11 +347,11 @@ def test_pull_non_workspace(tmp_dir, scm, dvc, erepo_dir):
|
||||
|
||||
|
||||
def test_import_non_existing(erepo_dir, tmp_dir, dvc):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
with pytest.raises(PathMissingError):
|
||||
tmp_dir.dvc.imp(os.fspath(erepo_dir), "invalid_output")
|
||||
|
||||
# https://github.com/iterative/dvc/pull/2837#discussion_r352123053
|
||||
with pytest.raises(FileNotFoundError):
|
||||
with pytest.raises(PathMissingError):
|
||||
tmp_dir.dvc.imp(os.fspath(erepo_dir), "/root/", "root")
|
||||
|
||||
|
||||
@@ -530,7 +530,7 @@ def test_import_with_no_exec(tmp_dir, dvc, erepo_dir):
|
||||
|
||||
|
||||
def test_import_with_jobs(mocker, dvc, erepo_dir):
|
||||
from dvc.data_cloud import DataCloud
|
||||
import dvc as dvc_module
|
||||
|
||||
with erepo_dir.chdir():
|
||||
erepo_dir.dvc_gen(
|
||||
@@ -545,7 +545,57 @@ def test_import_with_jobs(mocker, dvc, erepo_dir):
|
||||
commit="init",
|
||||
)
|
||||
|
||||
spy = mocker.spy(DataCloud, "pull")
|
||||
spy = mocker.spy(dvc_module.objects, "save")
|
||||
dvc.imp(os.fspath(erepo_dir), "dir1", jobs=3)
|
||||
run_jobs = tuple(spy.call_args_list[0])[1].get("jobs")
|
||||
assert run_jobs == 3
|
||||
|
||||
|
||||
def test_chained_import(tmp_dir, dvc, make_tmp_dir, erepo_dir, local_cloud):
|
||||
erepo_dir.add_remote(config=local_cloud.config)
|
||||
with erepo_dir.chdir():
|
||||
erepo_dir.dvc_gen({"dir": {"foo": "foo", "bar": "bar"}}, commit="init")
|
||||
erepo_dir.dvc.push()
|
||||
remove(erepo_dir.dvc.odb.local.cache_dir)
|
||||
remove(os.fspath(erepo_dir / "dir"))
|
||||
|
||||
erepo2 = make_tmp_dir("erepo2", scm=True, dvc=True)
|
||||
with erepo2.chdir():
|
||||
erepo2.dvc.imp(os.fspath(erepo_dir), "dir")
|
||||
erepo2.scm.add("dir.dvc")
|
||||
erepo2.scm.commit("import")
|
||||
remove(erepo2.dvc.odb.local.cache_dir)
|
||||
remove(os.fspath(erepo2 / "dir"))
|
||||
|
||||
dvc.imp(os.fspath(erepo2), "dir", "dir_imported")
|
||||
dst = tmp_dir / "dir_imported"
|
||||
assert (dst / "foo").read_text() == "foo"
|
||||
assert (dst / "bar").read_text() == "bar"
|
||||
|
||||
remove(dvc.odb.local.cache_dir)
|
||||
remove("dir_imported")
|
||||
|
||||
# pulled objects should come from the original upstream repo's remote,
|
||||
# no cache or remote should be needed from the intermediate repo
|
||||
dvc.pull(["dir_imported.dvc"])
|
||||
assert not os.path.exists(erepo_dir.dvc.odb.local.cache_dir)
|
||||
assert not os.path.exists(erepo2.dvc.odb.local.cache_dir)
|
||||
assert (dst / "foo").read_text() == "foo"
|
||||
assert (dst / "bar").read_text() == "bar"
|
||||
|
||||
|
||||
def test_circular_import(tmp_dir, dvc, scm, erepo_dir):
|
||||
from dvc.exceptions import CircularImportError
|
||||
|
||||
with erepo_dir.chdir():
|
||||
erepo_dir.dvc_gen({"dir": {"foo": "foo", "bar": "bar"}}, commit="init")
|
||||
|
||||
dvc.imp(os.fspath(erepo_dir), "dir", "dir_imported")
|
||||
scm.add("dir_imported.dvc")
|
||||
scm.commit("import")
|
||||
|
||||
with erepo_dir.chdir():
|
||||
with pytest.raises(CircularImportError):
|
||||
erepo_dir.dvc.imp(
|
||||
os.fspath(tmp_dir), "dir_imported", "circular_import"
|
||||
)
|
||||
|
||||
@@ -123,6 +123,7 @@ def test_import_url_with_no_exec(tmp_dir, dvc, erepo_dir):
|
||||
[
|
||||
pytest.lazy_fixture("local_cloud"),
|
||||
pytest.lazy_fixture("s3"),
|
||||
pytest.lazy_fixture("azure"),
|
||||
pytest.param(
|
||||
pytest.lazy_fixture("gs"), marks=pytest.mark.needs_internet
|
||||
),
|
||||
@@ -282,6 +283,7 @@ def test_import_url_to_remote_single_file(
|
||||
|
||||
hash_info = stage.outs[0].hash_info
|
||||
assert local_remote.hash_to_path_info(hash_info.value).read_text() == "foo"
|
||||
assert hash_info.size == len("foo")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -273,6 +273,24 @@ def test_ls_repo_with_removed_dvc_dir_with_path_file(tmp_dir, dvc, scm):
|
||||
match_files(files, ((("file",), True),))
|
||||
|
||||
|
||||
def test_ls_repo_with_rev(erepo_dir):
|
||||
with erepo_dir.chdir():
|
||||
erepo_dir.scm_gen(FS_STRUCTURE, commit="init")
|
||||
erepo_dir.dvc_gen(DVC_STRUCTURE, commit="dvc")
|
||||
|
||||
rev = erepo_dir.scm.list_all_commits()[1]
|
||||
files = Repo.ls(os.fspath(erepo_dir), rev=rev)
|
||||
match_files(
|
||||
files,
|
||||
(
|
||||
((".dvcignore",), False),
|
||||
((".gitignore",), False),
|
||||
(("README.md",), False),
|
||||
(("model",), False),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_ls_remote_repo(erepo_dir):
|
||||
with erepo_dir.chdir():
|
||||
erepo_dir.scm_gen(FS_STRUCTURE, commit="init")
|
||||
|
||||
+23
-1
@@ -1,11 +1,12 @@
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
from dvc.dvcfile import DVC_FILE_SUFFIX
|
||||
from dvc.exceptions import DvcException, MoveNotDataSourceError
|
||||
from dvc.main import main
|
||||
from dvc.utils.serialize import load_yaml
|
||||
from dvc.utils.serialize import dump_yaml, load_yaml
|
||||
from tests.basic_env import TestDvc, TestDvcGit
|
||||
from tests.func.test_repro import TestRepro
|
||||
from tests.utils import cd
|
||||
@@ -244,3 +245,24 @@ def test_move_output_overlap(tmp_dir, dvc):
|
||||
assert (tmp_dir / "foo.dvc").exists()
|
||||
assert not (tmp_dir / "dir" / "foo").exists()
|
||||
assert not (tmp_dir / "dir" / "foo.dvc").exists()
|
||||
|
||||
|
||||
def test_move_meta(tmp_dir, dvc):
|
||||
(stage,) = tmp_dir.dvc_gen("foo", "foo")
|
||||
data = load_yaml(stage.path)
|
||||
data["meta"] = {"custom_key": 42}
|
||||
dump_yaml(stage.path, data)
|
||||
|
||||
dvc.move("foo", "bar")
|
||||
res = (tmp_dir / "bar.dvc").read_text()
|
||||
print(res)
|
||||
assert res == textwrap.dedent(
|
||||
"""\
|
||||
outs:
|
||||
- md5: acbd18db4cc2f85cedef654fccc4a4d8
|
||||
size: 3
|
||||
path: bar
|
||||
meta:
|
||||
custom_key: 42
|
||||
"""
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user