mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-02 15:28:30 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a3cb803c | ||
|
|
41ecd2e204 | ||
|
|
33b3afa2f5 | ||
|
|
e849162018 | ||
|
|
c14f96391a | ||
|
|
88d3582d40 | ||
|
|
8fa0e40669 | ||
|
|
a3d6b1299a | ||
|
|
20c7b0e3e6 | ||
|
|
6794dd28f7 | ||
|
|
1cc803d12b | ||
|
|
2b6aa01392 | ||
|
|
906c87fdef | ||
|
|
52f02d238d | ||
|
|
3d791778de | ||
|
|
1e77be3ae3 | ||
|
|
844f77b4f9 | ||
|
|
34ef1807be | ||
|
|
dae0000bcd | ||
|
|
109d0e4e28 | ||
|
|
68b0d61c9e | ||
|
|
942a080567 | ||
|
|
60cbcec399 | ||
|
|
0c5a18513c | ||
|
|
b5c28dd9f9 | ||
|
|
84439e6a04 | ||
|
|
75fa487406 | ||
|
|
0ecd47b333 | ||
|
|
9710cafd38 | ||
|
|
e34767ca90 | ||
|
|
9a2feaa4ed | ||
|
|
7bc98e3fb3 | ||
|
|
5e561c1dfd | ||
|
|
572b6dfca9 | ||
|
|
37921d618d | ||
|
|
95c6cea36d | ||
|
|
455260b29c | ||
|
|
82dc67d890 | ||
|
|
2057c471bb | ||
|
|
f23d31af64 | ||
|
|
cf5d3830c5 | ||
|
|
32cd6c2c03 | ||
|
|
54917e3255 | ||
|
|
e8a439df8d | ||
|
|
71a4b4823a | ||
|
|
01cc97f38a | ||
|
|
3872f79dad | ||
|
|
48176b80f6 | ||
|
|
8c9a9e9a1f | ||
|
|
120eb09c4d | ||
|
|
bf7892012d | ||
|
|
36605eea4c | ||
|
|
01e5f9eadd | ||
|
|
752e10a834 | ||
|
|
092eda183c | ||
|
|
d82e094b13 | ||
|
|
570e3a5359 | ||
|
|
b8e770747f | ||
|
|
1999ecf0d3 | ||
|
|
1ff5f30839 | ||
|
|
c6ae91c907 | ||
|
|
dc1d3e880a | ||
|
|
3f5757a0a5 | ||
|
|
7a795f79db | ||
|
|
b3fb0dfdad | ||
|
|
ee29888e18 | ||
|
|
11eaa87849 | ||
|
|
b4faa5616f | ||
|
|
4d6df989f2 | ||
|
|
e585c52187 | ||
|
|
4add88f627 | ||
|
|
aac2fc0ca0 | ||
|
|
005d56eedf | ||
|
|
b6d25825c8 | ||
|
|
799dca8b67 | ||
|
|
3faf413288 | ||
|
|
a3261637de | ||
|
|
b8d738220d | ||
|
|
fb36db450f | ||
|
|
946ceb288d | ||
|
|
8d99ce06aa | ||
|
|
9b3873c4f0 | ||
|
|
e177f041c8 | ||
|
|
a80a85e6bd | ||
|
|
14c9348f93 | ||
|
|
7a1f133f29 | ||
|
|
45a01c6a30 | ||
|
|
09afd2a0cd | ||
|
|
d92c6fed67 | ||
|
|
f10dbef0cf | ||
|
|
8fb2f65d4b | ||
|
|
477f266dc5 | ||
|
|
b14a972cd0 | ||
|
|
562e61235b | ||
|
|
9bd9ac4a37 | ||
|
|
f89f4ac152 | ||
|
|
54dd3ae90c | ||
|
|
35d2963069 | ||
|
|
1f5f2f5c97 | ||
|
|
c64db917f5 | ||
|
|
9d476aab8b | ||
|
|
5067e1f5bc | ||
|
|
8a0c5680e0 | ||
|
|
61699d1d66 | ||
|
|
94c776182e | ||
|
|
e3d3500db8 | ||
|
|
d0e05c4b66 | ||
|
|
58d960a47c | ||
|
|
dd187df667 | ||
|
|
b71ea349d2 | ||
|
|
c81d841fd7 | ||
|
|
127a9c74b2 | ||
|
|
e30d2fdda5 | ||
|
|
ae4f56c5ad | ||
|
|
8cdcf9b8bb | ||
|
|
55a4532f1d | ||
|
|
43b9cab01b | ||
|
|
36d53bd403 | ||
|
|
46fac33ba5 | ||
|
|
8b68e4fa75 | ||
|
|
4f1fdf0b0c | ||
|
|
0d48598e4b | ||
|
|
5d64f6504f | ||
|
|
3d54039614 | ||
|
|
cda855b97a | ||
|
|
bea7d56bd5 | ||
|
|
10bcc9971d | ||
|
|
700b648da9 | ||
|
|
f84f9cf184 | ||
|
|
b53ebc1a5e | ||
|
|
31e8eed402 | ||
|
|
112ea1d448 | ||
|
|
f39e35445a | ||
|
|
7211bd02ed | ||
|
|
83795ff3df | ||
|
|
8ead29805f | ||
|
|
fecc81e951 | ||
|
|
7599c6e20d | ||
|
|
68a3cd9a13 | ||
|
|
a394498067 | ||
|
|
7708a8e292 | ||
|
|
23e62a9ff7 | ||
|
|
fed0bfb2b3 | ||
|
|
f22da81999 | ||
|
|
22a988b113 | ||
|
|
77a8e870ff | ||
|
|
f20d6a7048 | ||
|
|
6ffba62b75 | ||
|
|
5c561572b0 | ||
|
|
444bf27e6f | ||
|
|
ba82faee53 | ||
|
|
72d57f13bc | ||
|
|
02b2ab6804 | ||
|
|
fd440df5b3 | ||
|
|
cc8a0ce134 | ||
|
|
5c1e7be909 | ||
|
|
179e080c2f | ||
|
|
f3a7ec7f48 | ||
|
|
c0580c740b | ||
|
|
b4dcadbc9c | ||
|
|
55219e9089 | ||
|
|
70b57d4703 | ||
|
|
5eb16ab05e | ||
|
|
517f45d45f | ||
|
|
53c7f44480 | ||
|
|
9d75e8261b | ||
|
|
b3b0ec1fba | ||
|
|
0a3f5cbdd5 | ||
|
|
bdd21fd481 | ||
|
|
394118ef3d | ||
|
|
f500c66aef | ||
|
|
318235f072 | ||
|
|
ab395dfb7a | ||
|
|
dd9bc116a3 | ||
|
|
0a3dd7a97f | ||
|
|
5da72fc8ff | ||
|
|
d8dac6f50d | ||
|
|
36c27ed17f | ||
|
|
43f5faa652 | ||
|
|
968f9b38bf | ||
|
|
aab7f8af4d | ||
|
|
79c87ba119 | ||
|
|
aa5cfc46ed | ||
|
|
69acbe33c9 | ||
|
|
7dd0cffd49 | ||
|
|
a11aac37ea | ||
|
|
6de1ddadf5 | ||
|
|
7804e6550e | ||
|
|
65d935e5c7 | ||
|
|
44af5b3b7e | ||
|
|
03f553c54b | ||
|
|
1e0a4a26eb | ||
|
|
ee0a47f166 | ||
|
|
fa7b371c74 | ||
|
|
117b22d58a | ||
|
|
bfb1b74923 | ||
|
|
d600eeca99 | ||
|
|
0470e164cc | ||
|
|
0ab86c8fae | ||
|
|
067442f348 | ||
|
|
b58ebff5db | ||
|
|
565a813e2a | ||
|
|
dc0bc0c535 | ||
|
|
65397517ca | ||
|
|
1a3a222239 | ||
|
|
5ff1d76826 | ||
|
|
582c4afc38 | ||
|
|
b1284cb4af | ||
|
|
7d0d6a38d7 | ||
|
|
700654cb1e | ||
|
|
5a7790dcd5 | ||
|
|
ead7724824 | ||
|
|
ddd6c97f71 | ||
|
|
b71a0f30fb | ||
|
|
9930551458 | ||
|
|
3322980ff6 | ||
|
|
23a86c9a15 | ||
|
|
57419273ea | ||
|
|
879c9bd7d6 | ||
|
|
d09d39538a | ||
|
|
b684b2b25a | ||
|
|
7de443d8bc | ||
|
|
d008054ea2 | ||
|
|
b92bc3e01a | ||
|
|
c08aa4a93b | ||
|
|
81eee05995 | ||
|
|
325ead5e8b | ||
|
|
5cf59e4ee6 | ||
|
|
290a593322 | ||
|
|
19dcba9db6 | ||
|
|
ab54b5bdfc | ||
|
|
2e21be992d | ||
|
|
9d96ddfbe7 | ||
|
|
a3bab48f41 | ||
|
|
a13f2770ef | ||
|
|
079714772c | ||
|
|
cd65400d83 | ||
|
|
0d06a98c67 | ||
|
|
3fc8dcfc59 | ||
|
|
e80e725fe4 | ||
|
|
6a6955e27c | ||
|
|
efc021da73 | ||
|
|
244637e299 | ||
|
|
d033345860 | ||
|
|
de390419a6 | ||
|
|
d80c43135f | ||
|
|
5249207ceb | ||
|
|
2ebf9cb9b9 | ||
|
|
17899bc109 | ||
|
|
380c65134e | ||
|
|
ab024a47b3 | ||
|
|
fc3f3ca8d7 | ||
|
|
6bb3794f52 | ||
|
|
a2e25cf449 | ||
|
|
59b8498905 | ||
|
|
a378e017e1 | ||
|
|
4c71aadc21 | ||
|
|
3b4d76df42 | ||
|
|
6c04d0efd1 | ||
|
|
f41651c97c | ||
|
|
fcddf080da | ||
|
|
82b0a3088f | ||
|
|
30fa3fc41b | ||
|
|
d8fa55bafa | ||
|
|
220c633497 | ||
|
|
3767b03784 | ||
|
|
c0f63efe99 | ||
|
|
0b18efb7b7 | ||
|
|
497eac952c | ||
|
|
6eb5c31dd7 | ||
|
|
69ce69e427 | ||
|
|
f8467cf375 | ||
|
|
2735fbac71 |
@@ -0,0 +1,4 @@
|
||||
/tests
|
||||
/dvc
|
||||
/.github
|
||||
/bin
|
||||
@@ -14,9 +14,9 @@ jobs:
|
||||
uses: styfle/[email protected]
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- uses: actions/setup-python@v2
|
||||
- uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: 3.7
|
||||
python-version: 3.8
|
||||
- uses: iterative/dvc-bench@main
|
||||
with:
|
||||
pytest_options: "-k 'test_init or test_help'"
|
||||
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
deb:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2.4.0
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Set up Ruby 2.4
|
||||
@@ -41,11 +41,11 @@ jobs:
|
||||
rpm:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2.4.0
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Set up Ruby 2.4
|
||||
@@ -72,11 +72,11 @@ jobs:
|
||||
osxpkg:
|
||||
runs-on: macos-10.15
|
||||
steps:
|
||||
- uses: actions/checkout@v2.4.0
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Set up Ruby 2.4
|
||||
@@ -106,11 +106,11 @@ jobs:
|
||||
exe:
|
||||
runs-on: windows-2019
|
||||
steps:
|
||||
- uses: actions/checkout@v2.4.0
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install deps
|
||||
@@ -136,13 +136,13 @@ jobs:
|
||||
pip:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2.4.0
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v2.2.2
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: 3.7
|
||||
python-version: 3.8
|
||||
- name: Install
|
||||
run: pip install ".[all,tests]"
|
||||
- name: Build packages
|
||||
|
||||
@@ -20,20 +20,21 @@ jobs:
|
||||
uses: styfle/[email protected]
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- uses: actions/checkout@v2.4.0
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: 3.8
|
||||
- name: Install requirements
|
||||
run: |
|
||||
pip install --upgrade pip setuptools wheel
|
||||
pip install collective.checkdocs==0.2
|
||||
pip install ".[dev]" pre-commit
|
||||
- name: Check README
|
||||
run: python setup.py checkdocs
|
||||
- uses: pre-commit/action@v2.0.3
|
||||
- uses: pre-commit/action@v3.0.0
|
||||
tests:
|
||||
timeout-minutes: 45
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -41,13 +42,13 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-20.04, windows-latest, macos-latest]
|
||||
pyv: ["3.7", "3.8", "3.9", "3.10"]
|
||||
pyv: ["3.8", "3.9", "3.10"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2.4.0
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: ${{ matrix.pyv }}
|
||||
- name: get pip cache dir
|
||||
@@ -56,7 +57,7 @@ jobs:
|
||||
echo "::set-output name=dir::$(pip cache dir)"
|
||||
- name: set pip cache
|
||||
id: pip-cache
|
||||
uses: actions/cache@v2.1.7
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('setup.cfg') }}
|
||||
@@ -74,7 +75,7 @@ jobs:
|
||||
--cov-report=xml --cov-report=term
|
||||
${{ env.extra_test_args }}
|
||||
- name: upload coverage report
|
||||
uses: codecov/codecov-action@v2.1.0
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
+13
-3
@@ -1,10 +1,13 @@
|
||||
__pycache__/
|
||||
neatlynx.conf
|
||||
neatlynx/__pycache__
|
||||
.idea
|
||||
/cache
|
||||
*.pyc
|
||||
.env/
|
||||
.env2.7/
|
||||
|
||||
.env*/
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
.python-version
|
||||
|
||||
.dvc.conf.lock
|
||||
@@ -26,6 +29,7 @@ innosetup/config.ini
|
||||
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
|
||||
*.sw?
|
||||
|
||||
@@ -40,3 +44,9 @@ scripts/ci/gcp-creds.json
|
||||
|
||||
*~
|
||||
/dvc/_dvc_version.py
|
||||
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.nox/
|
||||
.tox/
|
||||
htmlcov/
|
||||
|
||||
@@ -6,9 +6,9 @@ repos:
|
||||
- id: black
|
||||
language_version: python3
|
||||
repo: https://github.com/ambv/black
|
||||
rev: 22.1.0
|
||||
rev: 22.3.0
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.1.0
|
||||
rev: v4.2.0
|
||||
hooks:
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
|
||||
+27
-16
@@ -14,25 +14,34 @@
|
||||
|
||||
|
|
||||
|
||||
**Data Version Control** or **DVC** is an **open-source** tool for data science and machine
|
||||
learning projects. Key features:
|
||||
**Data Version Control** or **DVC** helps you develop reproducible machine
|
||||
learning projects:
|
||||
|
||||
#. Simple **command line** Git-like experience. Does not require installing and maintaining
|
||||
any databases. Does not depend on any proprietary online services.
|
||||
#. Version your data and models.
|
||||
|
||||
#. Management and versioning of **datasets** and **machine learning
|
||||
models**. Data can be saved in S3, Google cloud, Azure, Alibaba
|
||||
cloud, SSH server, HDFS, or even local HDD RAID.
|
||||
#. Connect them to your code and parameters to form a pipeline.
|
||||
|
||||
#. Makes projects **reproducible** and **shareable**; helping to answer questions about how
|
||||
a model was built.
|
||||
#. Modify data, code, or parameters and run your modified pipeline.
|
||||
|
||||
#. Helps manage experiments with Git tags/branches and **metrics** tracking.
|
||||
#. Iterate and track each experiment.
|
||||
|
||||
**DVC** aims to replace spreadsheet and document sharing tools (such as Excel or Google Docs)
|
||||
frequently used as both knowledge repositories and team ledgers.
|
||||
DVC also replaces both ad-hoc scripts to track, move, and deploy different model versions
|
||||
and ad-hoc data file suffixes and prefixes.
|
||||
#. Share, compare, and reconstruct experiments.
|
||||
|
||||
**Why DVC?**
|
||||
|
||||
#. **Store** data and models (big) in your cloud storage, but keep their version
|
||||
info (small) in your Git repo.
|
||||
|
||||
#. **Iterate** fast with straightforward YAML pipelines. When you make changes,
|
||||
only run the steps impacted by those changes.
|
||||
|
||||
#. **Track** experiments in your local Git repo (no servers needed) and choose
|
||||
which to keep and share.
|
||||
|
||||
#. **Compare** any data, code, parameters, metrics, or plots and analyze however
|
||||
you want.
|
||||
|
||||
#. **Reconstruct** anyone's experiment with a single command.
|
||||
|
||||
.. contents:: **Contents**
|
||||
:backlinks: none
|
||||
@@ -169,14 +178,16 @@ Ubuntu / Debian (deb)
|
||||
.. code-block:: bash
|
||||
|
||||
sudo wget https://dvc.org/deb/dvc.list -O /etc/apt/sources.list.d/dvc.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install dvc
|
||||
wget -qO - https://dvc.org/deb/iterative.asc | sudo apt-key add -
|
||||
sudo apt update
|
||||
sudo apt install dvc
|
||||
|
||||
Fedora / CentOS (rpm)
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
.. code-block:: bash
|
||||
|
||||
sudo wget https://dvc.org/rpm/dvc.repo -O /etc/yum.repos.d/dvc.repo
|
||||
sudo rpm --import https://dvc.org/rpm/iterative.asc
|
||||
sudo yum update
|
||||
sudo yum install dvc
|
||||
|
||||
|
||||
+8
-7
@@ -19,17 +19,18 @@ def get_url(path, repo=None, rev=None, remote=None):
|
||||
directory in the remote storage.
|
||||
"""
|
||||
with Repo.open(repo, rev=rev, subrepos=True, uninitialized=True) as _repo:
|
||||
fs_path = _repo.fs.path.join(_repo.root_dir, path)
|
||||
fs_path = _repo.dvcfs.from_os_path(path)
|
||||
with reraise(FileNotFoundError, PathMissingError(path, repo)):
|
||||
info = _repo.repo_fs.info(fs_path)
|
||||
info = _repo.dvcfs.info(fs_path)
|
||||
|
||||
if not info["isdvc"]:
|
||||
dvc_info = info.get("dvc_info")
|
||||
if not dvc_info:
|
||||
raise OutputNotFoundError(path, repo)
|
||||
|
||||
cloud = info["repo"].cloud
|
||||
dvc_path = _repo.fs.path.relpath(fs_path, info["repo"].root_dir)
|
||||
md5 = info["repo"].dvcfs.info(dvc_path)["md5"]
|
||||
return cloud.get_url_for(remote, checksum=md5)
|
||||
dvc_repo = info["repo"]
|
||||
md5 = dvc_info["md5"]
|
||||
|
||||
return dvc_repo.cloud.get_url_for(remote, checksum=md5)
|
||||
|
||||
|
||||
def open( # noqa, pylint: disable=redefined-builtin
|
||||
|
||||
+111
-31
@@ -1,10 +1,12 @@
|
||||
"""This module provides an entrypoint to the dvc cli and parsing utils."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
# Workaround for CPython bug. See [1] and [2] for more info.
|
||||
# [1] https://github.com/aws/aws-cli/blob/1.16.277/awscli/clidriver.py#L55
|
||||
# [2] https://bugs.python.org/issue29288
|
||||
from typing import Optional
|
||||
|
||||
"".encode("idna")
|
||||
|
||||
@@ -31,9 +33,104 @@ def parse_args(argv=None):
|
||||
|
||||
parser = get_main_parser()
|
||||
args = parser.parse_args(argv)
|
||||
args.parser = parser
|
||||
return args
|
||||
|
||||
|
||||
def _log_unknown_exceptions() -> None:
|
||||
from dvc.info import get_dvc_info
|
||||
from dvc.logger import FOOTER
|
||||
|
||||
logger.exception("unexpected error")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Version info for developers:\n%s", get_dvc_info())
|
||||
logger.info(FOOTER)
|
||||
return None
|
||||
|
||||
|
||||
def _log_exceptions(exc: Exception) -> Optional[int]:
|
||||
"""Try to log some known exceptions, that are not DVCExceptions."""
|
||||
from dvc.utils import error_link, format_link
|
||||
|
||||
if isinstance(exc, OSError):
|
||||
import errno
|
||||
|
||||
if exc.errno == errno.EMFILE:
|
||||
logger.exception(
|
||||
"too many open files, please visit "
|
||||
"{} to see how to handle this "
|
||||
"problem".format(error_link("many-files")),
|
||||
extra={"tb_only": True},
|
||||
)
|
||||
else:
|
||||
_log_unknown_exceptions()
|
||||
return None
|
||||
|
||||
from dvc.fs import AuthError, ConfigError, RemoteMissingDepsError
|
||||
|
||||
if isinstance(exc, RemoteMissingDepsError):
|
||||
from dvc.utils.pkg import PKG
|
||||
|
||||
proto = exc.protocol
|
||||
by_pkg = {
|
||||
"pip": f"pip install 'dvc[{proto}]'",
|
||||
"conda": f"conda install -c conda-forge dvc-{proto}",
|
||||
}
|
||||
|
||||
cmd = by_pkg.get(PKG)
|
||||
if cmd:
|
||||
link = format_link("https://dvc.org/doc/install")
|
||||
hint = (
|
||||
f"To install dvc with those dependencies, run:\n"
|
||||
"\n"
|
||||
f"\t{cmd}\n"
|
||||
"\n"
|
||||
f"See {link} for more info."
|
||||
)
|
||||
else:
|
||||
link = format_link("https://github.com/iterative/dvc/issues")
|
||||
hint = f"\nPlease report this bug to {link}. Thank you!"
|
||||
|
||||
logger.exception(
|
||||
f"URL '{exc.url}' is supported but requires these missing "
|
||||
f"dependencies: {exc.missing_deps}. {hint}",
|
||||
extra={"tb_only": True},
|
||||
)
|
||||
return None
|
||||
|
||||
if isinstance(exc, (AuthError, ConfigError)):
|
||||
link = format_link("https://man.dvc.org/remote/modify")
|
||||
logger.exception("configuration error")
|
||||
logger.exception(
|
||||
f"{exc!s}\nLearn more about configuration settings at {link}.",
|
||||
extra={"tb_only": True},
|
||||
)
|
||||
return 251
|
||||
|
||||
from dvc_objects.cache import DiskError
|
||||
|
||||
if isinstance(exc, DiskError):
|
||||
from dvc.utils import relpath
|
||||
|
||||
directory = relpath(exc.directory)
|
||||
logger.exception(
|
||||
f"Could not open pickled '{exc.type}' cache.\n"
|
||||
f"Remove the '{directory}' directory and then retry this command."
|
||||
f"\nSee {error_link('pickle')} for more information.",
|
||||
extra={"tb_only": True},
|
||||
)
|
||||
return None
|
||||
|
||||
from dvc_data.stage import IgnoreInCollectedDirError
|
||||
|
||||
if isinstance(exc, IgnoreInCollectedDirError):
|
||||
logger.exception("")
|
||||
return None
|
||||
|
||||
_log_unknown_exceptions()
|
||||
return None
|
||||
|
||||
|
||||
def main(argv=None): # noqa: C901
|
||||
"""Main entry point for dvc CLI.
|
||||
|
||||
@@ -46,7 +143,17 @@ def main(argv=None): # noqa: C901
|
||||
from dvc._debug import debugtools
|
||||
from dvc.config import ConfigError
|
||||
from dvc.exceptions import DvcException, NotDvcRepoError
|
||||
from dvc.logger import FOOTER, disable_other_loggers
|
||||
from dvc.logger import disable_other_loggers, set_loggers_level
|
||||
|
||||
# NOTE: stderr/stdout may be closed if we are running from dvc.daemon.
|
||||
# On Linux we directly call cli.main after double forking and closing
|
||||
# the copied parent's standard file descriptors. If we make any logging
|
||||
# calls in this state it will cause an exception due to writing to a closed
|
||||
# file descriptor.
|
||||
if sys.stderr.closed: # pylint: disable=using-constant-test
|
||||
logging.disable()
|
||||
elif sys.stdout.closed: # pylint: disable=using-constant-test
|
||||
logging.disable(logging.INFO)
|
||||
|
||||
args = None
|
||||
disable_other_loggers()
|
||||
@@ -64,11 +171,11 @@ def main(argv=None): # noqa: C901
|
||||
level = logging.TRACE
|
||||
|
||||
if level is not None:
|
||||
logger.setLevel(level)
|
||||
set_loggers_level(level)
|
||||
|
||||
logger.trace(args)
|
||||
|
||||
if not args.quiet:
|
||||
if not sys.stdout.closed and not args.quiet:
|
||||
from dvc.ui import ui
|
||||
|
||||
ui.enable()
|
||||
@@ -92,27 +199,7 @@ def main(argv=None): # noqa: C901
|
||||
ret = 254
|
||||
except Exception as exc: # noqa, pylint: disable=broad-except
|
||||
# pylint: disable=no-member
|
||||
import errno
|
||||
|
||||
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
|
||||
from dvc.utils import error_link
|
||||
|
||||
logger.exception(
|
||||
"too many open files, please visit "
|
||||
"{} to see how to handle this "
|
||||
"problem".format(error_link("many-files")),
|
||||
extra={"tb_only": True},
|
||||
)
|
||||
else:
|
||||
from dvc.info import get_dvc_info
|
||||
|
||||
logger.exception("unexpected error")
|
||||
|
||||
dvc_info = get_dvc_info()
|
||||
logger.debug("Version info for developers:\n%s", dvc_info)
|
||||
|
||||
logger.info(FOOTER)
|
||||
ret = 255
|
||||
ret = _log_exceptions(exc) or 255
|
||||
|
||||
try:
|
||||
from dvc import analytics
|
||||
@@ -124,13 +211,6 @@ def main(argv=None): # noqa: C901
|
||||
finally:
|
||||
logger.setLevel(outerLogLevel)
|
||||
|
||||
from dvc.fs.pool import close_pools
|
||||
|
||||
# Closing pools by-hand to prevent weird messages when closing SSH
|
||||
# connections. See https://github.com/iterative/dvc/issues/3248 for
|
||||
# more info.
|
||||
close_pools()
|
||||
|
||||
from dvc.external_repo import clean_repos
|
||||
|
||||
# Remove cached repos in the end of the call, these are anonymous
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import shtab
|
||||
|
||||
BASH_PREAMBLE = """
|
||||
# $1=COMP_WORDS[1]
|
||||
_dvc_compgen_DVCFiles() {
|
||||
compgen -d -S '/' -- $1 # recurse into subdirs
|
||||
compgen -f -X '!*?.dvc' -- $1
|
||||
compgen -f -X '!*Dvcfile' -- $1
|
||||
compgen -f -X '!*dvc.yaml' -- $1
|
||||
}
|
||||
|
||||
_dvc_compgen_stages() {
|
||||
local _dvc_stages=($(dvc stage list -q --names-only))
|
||||
compgen -W "${_dvc_stages[*]}" -- $1
|
||||
}
|
||||
_dvc_compgen_stages_and_files() {
|
||||
_dvc_compgen_DVCFiles $1
|
||||
_dvc_compgen_stages $1
|
||||
}
|
||||
|
||||
_dvc_compgen_exps() {
|
||||
local _dvc_exps=($(dvc exp list -q --all-commits --names-only))
|
||||
compgen -W "${_dvc_exps[*]}" -- $1
|
||||
}
|
||||
"""
|
||||
|
||||
ZSH_PREAMBLE = """
|
||||
_dvc_compadd_DVCFiles() {
|
||||
_files -g '(*?.dvc|Dvcfile|dvc.yaml)'
|
||||
}
|
||||
_dvc_compadd_stages() {
|
||||
# this will also show up the description of the stages
|
||||
_describe 'stages' "($(_dvc_stages_output))"
|
||||
}
|
||||
|
||||
_dvc_stages_output() {
|
||||
dvc stage list -q | awk '{
|
||||
# escape possible `:` on the stage name
|
||||
sub(/:/, "\\\\\\\\:", $1);
|
||||
# read all of the columns except the first
|
||||
# reading `out` from $2, so as not to have a leading whitespace
|
||||
out=$2; for(i=3;i<=NF;i++){out=out" "$i};
|
||||
# print key, ":" and then single-quote the description
|
||||
# colon is a delimiter used by `_describe` to separate field/description
|
||||
print $1":""\\047"out"\\047"
|
||||
# single quote -> \\047
|
||||
}'
|
||||
}
|
||||
|
||||
_dvc_compadd_stages_and_files() {
|
||||
_dvc_compadd_DVCFiles
|
||||
_dvc_compadd_stages
|
||||
}
|
||||
|
||||
_dvc_compadd_exps() {
|
||||
_describe 'experiments' "($(dvc exp list -q --all-commits --names-only))"
|
||||
}
|
||||
"""
|
||||
|
||||
PREAMBLE = {
|
||||
"bash": BASH_PREAMBLE,
|
||||
"zsh": ZSH_PREAMBLE,
|
||||
}
|
||||
|
||||
FILE = shtab.FILE
|
||||
DIR = shtab.DIRECTORY
|
||||
DVC_FILE = {"bash": "_dvc_compgen_DVCFiles", "zsh": "_dvc_compadd_DVCFiles"}
|
||||
STAGE = {"bash": "_dvc_compgen_stages", "zsh": "_dvc_compadd_stages"}
|
||||
DVCFILES_AND_STAGE = {
|
||||
"bash": "_dvc_compgen_stages_and_files",
|
||||
"zsh": "_dvc_compadd_stages_and_files",
|
||||
}
|
||||
EXPERIMENT = {"bash": "_dvc_compgen_exps", "zsh": "_dvc_compadd_exps"}
|
||||
@@ -27,7 +27,6 @@ from dvc.commands import (
|
||||
imp_url,
|
||||
init,
|
||||
install,
|
||||
live,
|
||||
ls,
|
||||
machine,
|
||||
metrics,
|
||||
@@ -86,7 +85,6 @@ COMMANDS = [
|
||||
stage,
|
||||
experiments,
|
||||
check_ignore,
|
||||
live,
|
||||
machine,
|
||||
]
|
||||
|
||||
|
||||
+1
-2
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -106,7 +106,6 @@ def add_parser(subparsers, parent_parser):
|
||||
"Number of jobs to run simultaneously "
|
||||
"when pushing data to remote."
|
||||
"The default value is 4 * cpu_count(). "
|
||||
"For SSH remotes, the default is 4. "
|
||||
),
|
||||
metavar="<number>",
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import argparse
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.utils import append_doc_link, fix_subparsers
|
||||
from dvc.commands import completion
|
||||
from dvc.commands.config import CmdConfig
|
||||
from dvc.ui import ui
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import argparse
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.ui import ui
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import operator
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.exceptions import CheckoutError
|
||||
from dvc.ui import ui
|
||||
|
||||
@@ -55,6 +55,10 @@ class CmdCheckout(CmdBase):
|
||||
|
||||
if exc:
|
||||
raise exc
|
||||
|
||||
if self.args.relink:
|
||||
msg = "Relinked successfully"
|
||||
ui.write(msg)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,92 +1,20 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
import shtab
|
||||
|
||||
from dvc.cli.command import CmdBaseNoRepo
|
||||
from dvc.cli.completion import PREAMBLE
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
FILE = shtab.FILE
|
||||
DIR = shtab.DIRECTORY
|
||||
|
||||
PREAMBLE = {
|
||||
"bash": """
|
||||
# $1=COMP_WORDS[1]
|
||||
_dvc_compgen_DVCFiles() {
|
||||
compgen -d -S '/' -- $1 # recurse into subdirs
|
||||
compgen -f -X '!*?.dvc' -- $1
|
||||
compgen -f -X '!*Dvcfile' -- $1
|
||||
compgen -f -X '!*dvc.yaml' -- $1
|
||||
}
|
||||
|
||||
_dvc_compgen_stages() {
|
||||
local _dvc_stages=($(dvc stage list -q --names-only))
|
||||
compgen -W "${_dvc_stages[*]}" -- $1
|
||||
}
|
||||
_dvc_compgen_stages_and_files() {
|
||||
_dvc_compgen_DVCFiles $1
|
||||
_dvc_compgen_stages $1
|
||||
}
|
||||
|
||||
_dvc_compgen_exps() {
|
||||
local _dvc_exps=($(dvc exp list -q --all --names-only))
|
||||
compgen -W "${_dvc_exps[*]}" -- $1
|
||||
}
|
||||
""",
|
||||
"zsh": """
|
||||
_dvc_compadd_DVCFiles() {
|
||||
_files -g '(*?.dvc|Dvcfile|dvc.yaml)'
|
||||
}
|
||||
_dvc_compadd_stages() {
|
||||
# this will also show up the description of the stages
|
||||
_describe 'stages' "($(_dvc_stages_output))"
|
||||
}
|
||||
|
||||
_dvc_stages_output() {
|
||||
dvc stage list -q | awk '{
|
||||
# escape possible `:` on the stage name
|
||||
sub(/:/, "\\\\\\\\:", $1);
|
||||
# read all of the columns except the first
|
||||
# reading `out` from $2, so as not to have a leading whitespace
|
||||
out=$2; for(i=3;i<=NF;i++){out=out" "$i};
|
||||
# print key, ":" and then single-quote the description
|
||||
# colon is a delimiter used by `_describe` to separate field/description
|
||||
print $1":""\\047"out"\\047"
|
||||
# single quote -> \\047
|
||||
}'
|
||||
}
|
||||
|
||||
_dvc_compadd_stages_and_files() {
|
||||
_dvc_compadd_DVCFiles
|
||||
_dvc_compadd_stages
|
||||
}
|
||||
|
||||
_dvc_compadd_exps() {
|
||||
_describe 'experiments' "($(dvc exp list -q -a --names-only))"
|
||||
}
|
||||
""",
|
||||
}
|
||||
|
||||
DVC_FILE = {"bash": "_dvc_compgen_DVCFiles", "zsh": "_dvc_compadd_DVCFiles"}
|
||||
|
||||
STAGE = {"bash": "_dvc_compgen_stages", "zsh": "_dvc_compadd_stages"}
|
||||
|
||||
DVCFILES_AND_STAGE = {
|
||||
"bash": "_dvc_compgen_stages_and_files",
|
||||
"zsh": "_dvc_compadd_stages_and_files",
|
||||
}
|
||||
|
||||
EXPERIMENT = {"bash": "_dvc_compgen_exps", "zsh": "_dvc_compadd_exps"}
|
||||
|
||||
|
||||
class CmdCompletion(CmdBaseNoRepo):
|
||||
def run(self):
|
||||
from dvc.cli.parser import get_main_parser
|
||||
import shtab
|
||||
|
||||
parser = get_main_parser()
|
||||
shell = self.args.shell
|
||||
parser = self.args.parser
|
||||
script = shtab.complete(parser, shell=shell, preamble=PREAMBLE)
|
||||
ui.write(script, force=True)
|
||||
return 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBaseNoRepo
|
||||
from dvc.cli.utils import fix_subparsers
|
||||
from dvc.commands import completion
|
||||
|
||||
|
||||
class CmdDaemonBase(CmdBaseNoRepo):
|
||||
|
||||
+43
-1
@@ -24,10 +24,36 @@ def _show_dot(G):
|
||||
from networkx.drawing.nx_pydot import write_dot
|
||||
|
||||
dot_file = io.StringIO()
|
||||
write_dot(G, dot_file)
|
||||
write_dot(G.reverse(), dot_file)
|
||||
return dot_file.getvalue()
|
||||
|
||||
|
||||
def _show_mermaid(G, markdown: bool = False):
|
||||
from dvc.repo.graph import get_pipelines
|
||||
|
||||
pipelines = get_pipelines(G)
|
||||
|
||||
graph = "flowchart TD"
|
||||
|
||||
total_nodes = 0
|
||||
for pipeline in pipelines:
|
||||
node_ids = {}
|
||||
nodes = sorted(str(x) for x in pipeline.nodes)
|
||||
for node in nodes:
|
||||
total_nodes += 1
|
||||
node_id = f"node{total_nodes}"
|
||||
graph += f'\n\t{node_id}["{node}"]'
|
||||
node_ids[node] = node_id
|
||||
edges = sorted((str(a), str(b)) for b, a in pipeline.edges)
|
||||
for a, b in edges:
|
||||
graph += f"\n\t{node_ids[str(a)]}-->{node_ids[str(b)]}"
|
||||
|
||||
if markdown:
|
||||
return f"```mermaid\n{graph}\n```"
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
def _collect_targets(repo, target, outs):
|
||||
if not target:
|
||||
return []
|
||||
@@ -105,6 +131,8 @@ class CmdDAG(CmdBase):
|
||||
|
||||
if self.args.dot:
|
||||
ui.write(_show_dot(G))
|
||||
elif self.args.mermaid or self.args.markdown:
|
||||
ui.write(_show_mermaid(G, self.args.markdown))
|
||||
else:
|
||||
with ui.pager():
|
||||
ui.write(_show_ascii(G))
|
||||
@@ -127,6 +155,20 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Print DAG with .dot format.",
|
||||
)
|
||||
dag_parser.add_argument(
|
||||
"--mermaid",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Print DAG with mermaid format.",
|
||||
)
|
||||
dag_parser.add_argument(
|
||||
"--md",
|
||||
"--show-md",
|
||||
action="store_true",
|
||||
default=False,
|
||||
dest="markdown",
|
||||
help="Print DAG with mermaid format wrapped in Markdown block.",
|
||||
)
|
||||
dag_parser.add_argument(
|
||||
"--full",
|
||||
action="store_true",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -111,7 +111,6 @@ def shared_parent_parser():
|
||||
help=(
|
||||
"Number of jobs to run simultaneously. "
|
||||
"The default value is 4 * cpu_count(). "
|
||||
"For SSH remotes, the default is 4. "
|
||||
),
|
||||
metavar="<number>",
|
||||
)
|
||||
|
||||
@@ -2,9 +2,9 @@ import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -128,7 +128,7 @@ class CmdDiff(CmdBase):
|
||||
show_hash = self.args.show_hash
|
||||
hide_missing = self.args.b_rev or self.args.hide_missing
|
||||
if hide_missing:
|
||||
del diff["not in cache"]
|
||||
diff.pop("not in cache", None)
|
||||
|
||||
for key, entries in diff.items():
|
||||
entries = sorted(
|
||||
|
||||
@@ -58,3 +58,44 @@ def add_parser(subparsers, parent_parser):
|
||||
for cmd in SUB_COMMANDS:
|
||||
cmd.add_parser(experiments_subparsers, parent_parser)
|
||||
fix_plumbing_subparsers(experiments_subparsers)
|
||||
|
||||
|
||||
def add_rev_selection_flags(
|
||||
experiments_subcmd_parser, command: str, default: bool = True
|
||||
):
|
||||
experiments_subcmd_parser.add_argument(
|
||||
"-A",
|
||||
"--all-commits",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
f"{command} all experiments in the repository "
|
||||
"(overrides `--rev` and `--num`)."
|
||||
),
|
||||
)
|
||||
default_msg = " (HEAD by default)" if default else ""
|
||||
msg = (
|
||||
f"{command} experiments derived from the specified `<commit>` as "
|
||||
f"baseline{default_msg}."
|
||||
)
|
||||
experiments_subcmd_parser.add_argument(
|
||||
"--rev",
|
||||
type=str,
|
||||
default=None,
|
||||
help=msg,
|
||||
metavar="<commit>",
|
||||
)
|
||||
experiments_subcmd_parser.add_argument(
|
||||
"-n",
|
||||
"--num",
|
||||
type=int,
|
||||
default=1,
|
||||
dest="num",
|
||||
metavar="<num>",
|
||||
help=(
|
||||
f"{command} experiments from the last `num` commits "
|
||||
"(first parents) starting from the `--rev` baseline. "
|
||||
"Give a negative value to include all first-parent commits "
|
||||
"(similar to `git log -n`)."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.commands.metrics import DEFAULT_PRECISION
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.ui import ui
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import argparse
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from funcy import compact
|
||||
|
||||
@@ -8,6 +9,11 @@ from dvc.cli.utils import append_doc_link
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
from dvc.ui import ui
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.dependency import Dependency
|
||||
from dvc.stage import PipelineStage
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -19,7 +25,6 @@ class CmdExperimentsInit(CmdBase):
|
||||
DEFAULT_METRICS = "metrics.json"
|
||||
DEFAULT_PARAMS = "params.yaml"
|
||||
PLOTS = "plots"
|
||||
DVCLIVE = "dvclive"
|
||||
DEFAULTS = {
|
||||
"code": CODE,
|
||||
"data": DATA,
|
||||
@@ -27,9 +32,7 @@ class CmdExperimentsInit(CmdBase):
|
||||
"metrics": DEFAULT_METRICS,
|
||||
"params": DEFAULT_PARAMS,
|
||||
"plots": PLOTS,
|
||||
"live": DVCLIVE,
|
||||
}
|
||||
EXP_LINK = "https://s.dvc.org/g/exp/run"
|
||||
|
||||
def run(self):
|
||||
from dvc.commands.stage import parse_cmd
|
||||
@@ -58,7 +61,7 @@ class CmdExperimentsInit(CmdBase):
|
||||
}
|
||||
)
|
||||
|
||||
initialized_stage = init(
|
||||
initialized_stage, initialized_deps, initialized_out_dirs = init(
|
||||
self.repo,
|
||||
name=self.args.name,
|
||||
type=self.args.type,
|
||||
@@ -67,33 +70,53 @@ class CmdExperimentsInit(CmdBase):
|
||||
interactive=self.args.interactive,
|
||||
force=self.args.force,
|
||||
)
|
||||
|
||||
text = ui.rich_text.assemble(
|
||||
"\n" if self.args.interactive else "",
|
||||
"Created ",
|
||||
(self.args.name, "bright_blue"),
|
||||
" stage in ",
|
||||
("dvc.yaml", "green"),
|
||||
".",
|
||||
self._post_init_display(
|
||||
initialized_stage, initialized_deps, initialized_out_dirs
|
||||
)
|
||||
if not self.args.run:
|
||||
text.append_text(
|
||||
ui.rich_text.assemble(
|
||||
" To run, use ",
|
||||
('"dvc exp run"', "green"),
|
||||
".\nSee ",
|
||||
(self.EXP_LINK, "repr.url"),
|
||||
".",
|
||||
)
|
||||
)
|
||||
|
||||
ui.write(text, styled=True)
|
||||
if self.args.run:
|
||||
return self.repo.experiments.run(
|
||||
targets=[initialized_stage.addressing]
|
||||
self.repo.experiments.run(targets=[initialized_stage.addressing])
|
||||
return 0
|
||||
|
||||
def _post_init_display(
|
||||
self,
|
||||
stage: "PipelineStage",
|
||||
new_deps: List["Dependency"],
|
||||
new_out_dirs: List[str],
|
||||
) -> None:
|
||||
from dvc.utils import humanize
|
||||
|
||||
path_fmt = "[green]{}[/green]".format
|
||||
if new_deps:
|
||||
deps_paths = humanize.join(map(path_fmt, new_deps))
|
||||
ui.write(f"Creating dependencies: {deps_paths}", styled=True)
|
||||
|
||||
if new_out_dirs:
|
||||
out_dirs_paths = humanize.join(map(path_fmt, new_out_dirs))
|
||||
ui.write(
|
||||
f"Creating output directories: {out_dirs_paths}", styled=True
|
||||
)
|
||||
|
||||
return 0
|
||||
ui.write(
|
||||
f"Creating [b]{self.args.name}[/b] stage in [green]dvc.yaml[/]",
|
||||
styled=True,
|
||||
)
|
||||
if stage.outs or not self.args.run:
|
||||
# separate the above status-like messages with help/tips section
|
||||
ui.write(styled=True)
|
||||
|
||||
if stage.outs:
|
||||
outs_paths = humanize.join(map(path_fmt, stage.outs))
|
||||
tips = f"Ensure your experiment command creates {outs_paths}."
|
||||
ui.write(tips, styled=True)
|
||||
|
||||
if not self.args.run:
|
||||
ui.write(
|
||||
'You can now run your experiment using [b]"dvc exp run"[/].',
|
||||
styled=True,
|
||||
)
|
||||
else:
|
||||
# separate between `exp.run` output and `dvc exp init` output
|
||||
ui.write(styled=True)
|
||||
|
||||
|
||||
def add_parser(experiments_subparsers, parent_parser):
|
||||
@@ -176,12 +199,11 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--live",
|
||||
help="Path to log dvclive outputs for your experiments"
|
||||
f" (default: {CmdExperimentsInit.DVCLIVE})",
|
||||
help="Path to log dvclive outputs for your experiments",
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--type",
|
||||
choices=["default", "dl"],
|
||||
choices=["default", "checkpoint"],
|
||||
default="default",
|
||||
help="Select type of stage to create (default: %(default)s)",
|
||||
)
|
||||
|
||||
@@ -9,11 +9,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class CmdExperimentsList(CmdBase):
|
||||
def run(self):
|
||||
names_only = self.args.names_only
|
||||
name_only = self.args.name_only
|
||||
exps = self.repo.experiments.ls(
|
||||
all_commits=self.args.all_commits,
|
||||
rev=self.args.rev,
|
||||
num=self.args.num,
|
||||
git_remote=self.args.git_remote,
|
||||
all_=self.args.all,
|
||||
)
|
||||
for baseline in exps:
|
||||
tag = self.repo.scm.describe(baseline)
|
||||
@@ -22,16 +23,17 @@ class CmdExperimentsList(CmdBase):
|
||||
if branch:
|
||||
tag = branch.split("/")[-1]
|
||||
name = tag if tag else baseline[:7]
|
||||
if not names_only:
|
||||
if not name_only:
|
||||
print(f"{name}:")
|
||||
for exp_name in exps[baseline]:
|
||||
indent = "" if names_only else "\t"
|
||||
indent = "" if name_only else "\t"
|
||||
print(f"{indent}{exp_name}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def add_parser(experiments_subparsers, parent_parser):
|
||||
from . import add_rev_selection_flags
|
||||
|
||||
EXPERIMENTS_LIST_HELP = "List local and remote experiments."
|
||||
experiments_list_parser = experiments_subparsers.add_parser(
|
||||
@@ -41,20 +43,9 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
help=EXPERIMENTS_LIST_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
add_rev_selection_flags(experiments_list_parser, "List")
|
||||
experiments_list_parser.add_argument(
|
||||
"--rev",
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"List experiments derived from the specified revision. "
|
||||
"Defaults to HEAD if neither `--rev` nor `--all` are specified."
|
||||
),
|
||||
metavar="<rev>",
|
||||
)
|
||||
experiments_list_parser.add_argument(
|
||||
"--all", action="store_true", help="List all experiments."
|
||||
)
|
||||
experiments_list_parser.add_argument(
|
||||
"--name-only",
|
||||
"--names-only",
|
||||
action="store_true",
|
||||
help="Only output experiment names (without parent commits).",
|
||||
@@ -68,6 +59,6 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
"If provided, experiments from the specified Git repository "
|
||||
" will be listed instead of local ones."
|
||||
),
|
||||
metavar="[<git_remote>]",
|
||||
metavar="<git_remote>",
|
||||
)
|
||||
experiments_list_parser.set_defaults(func=CmdExperimentsList)
|
||||
|
||||
@@ -3,17 +3,31 @@ import logging
|
||||
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
from dvc.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdExperimentsPull(CmdBase):
|
||||
def run(self):
|
||||
def raise_error_if_all_disabled(self):
|
||||
if not any(
|
||||
[self.args.experiment, self.args.all_commits, self.args.rev]
|
||||
):
|
||||
raise InvalidArgumentError(
|
||||
"Either provide an `experiment` argument, or use the "
|
||||
"`--rev` or `--all-commits` flag."
|
||||
)
|
||||
|
||||
self.repo.experiments.pull(
|
||||
def run(self):
|
||||
self.raise_error_if_all_disabled()
|
||||
|
||||
pulled_exps = self.repo.experiments.pull(
|
||||
self.args.git_remote,
|
||||
self.args.experiment,
|
||||
all_commits=self.args.all_commits,
|
||||
rev=self.args.rev,
|
||||
num=self.args.num,
|
||||
force=self.args.force,
|
||||
pull_cache=self.args.pull_cache,
|
||||
dvc_remote=self.args.dvc_remote,
|
||||
@@ -21,10 +35,13 @@ class CmdExperimentsPull(CmdBase):
|
||||
run_cache=self.args.run_cache,
|
||||
)
|
||||
|
||||
ui.write(
|
||||
f"Pulled experiment '{self.args.experiment}'",
|
||||
f"from Git remote '{self.args.git_remote}'.",
|
||||
)
|
||||
if pulled_exps:
|
||||
ui.write(
|
||||
f"Pulled experiment '{pulled_exps}'",
|
||||
f"from Git remote '{self.args.git_remote}'.",
|
||||
)
|
||||
else:
|
||||
ui.write("No experiments to pull.")
|
||||
if not self.args.pull_cache:
|
||||
ui.write(
|
||||
"To pull cached outputs for this experiment"
|
||||
@@ -36,6 +53,8 @@ class CmdExperimentsPull(CmdBase):
|
||||
|
||||
|
||||
def add_parser(experiments_subparsers, parent_parser):
|
||||
from . import add_rev_selection_flags
|
||||
|
||||
EXPERIMENTS_PULL_HELP = "Pull an experiment from a Git remote."
|
||||
experiments_pull_parser = experiments_subparsers.add_parser(
|
||||
"pull",
|
||||
@@ -44,6 +63,7 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
help=EXPERIMENTS_PULL_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
add_rev_selection_flags(experiments_pull_parser, "Pull", False)
|
||||
experiments_pull_parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
@@ -89,7 +109,8 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
)
|
||||
experiments_pull_parser.add_argument(
|
||||
"experiment",
|
||||
nargs="+",
|
||||
nargs="*",
|
||||
default=None,
|
||||
help="Experiments to pull.",
|
||||
metavar="<experiment>",
|
||||
)
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
from dvc.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdExperimentsPush(CmdBase):
|
||||
def raise_error_if_all_disabled(self):
|
||||
if not any(
|
||||
[self.args.experiment, self.args.all_commits, self.args.rev]
|
||||
):
|
||||
raise InvalidArgumentError(
|
||||
"Either provide an `experiment` argument, or use the "
|
||||
"`--rev` or `--all-commits` flag."
|
||||
)
|
||||
|
||||
def run(self):
|
||||
|
||||
self.repo.experiments.push(
|
||||
self.raise_error_if_all_disabled()
|
||||
|
||||
pushed_exps = self.repo.experiments.push(
|
||||
self.args.git_remote,
|
||||
self.args.experiment,
|
||||
all_commits=self.args.all_commits,
|
||||
rev=self.args.rev,
|
||||
num=self.args.num,
|
||||
force=self.args.force,
|
||||
push_cache=self.args.push_cache,
|
||||
dvc_remote=self.args.dvc_remote,
|
||||
@@ -22,10 +37,13 @@ class CmdExperimentsPush(CmdBase):
|
||||
run_cache=self.args.run_cache,
|
||||
)
|
||||
|
||||
ui.write(
|
||||
f"Pushed experiment '{self.args.experiment}'"
|
||||
f"to Git remote '{self.args.git_remote}'."
|
||||
)
|
||||
if pushed_exps:
|
||||
ui.write(
|
||||
f"Pushed experiment '{pushed_exps}'"
|
||||
f"to Git remote '{self.args.git_remote}'."
|
||||
)
|
||||
else:
|
||||
ui.write("No experiments to push.")
|
||||
if not self.args.push_cache:
|
||||
ui.write(
|
||||
"To push cached outputs",
|
||||
@@ -37,6 +55,8 @@ class CmdExperimentsPush(CmdBase):
|
||||
|
||||
|
||||
def add_parser(experiments_subparsers, parent_parser):
|
||||
from . import add_rev_selection_flags
|
||||
|
||||
EXPERIMENTS_PUSH_HELP = "Push a local experiment to a Git remote."
|
||||
experiments_push_parser = experiments_subparsers.add_parser(
|
||||
"push",
|
||||
@@ -45,6 +65,7 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
help=EXPERIMENTS_PUSH_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
add_rev_selection_flags(experiments_push_parser, "Push", False)
|
||||
experiments_push_parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
@@ -90,7 +111,8 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
)
|
||||
experiments_push_parser.add_argument(
|
||||
"experiment",
|
||||
nargs="+",
|
||||
nargs="*",
|
||||
default=None,
|
||||
help="Experiments to push.",
|
||||
metavar="<experiment>",
|
||||
).complete = completion.EXPERIMENT
|
||||
|
||||
@@ -3,24 +3,47 @@ import logging
|
||||
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
from dvc.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdExperimentsRemove(CmdBase):
|
||||
def raise_error_if_all_disabled(self):
|
||||
if not any(
|
||||
[
|
||||
self.args.experiment,
|
||||
self.args.all_commits,
|
||||
self.args.rev,
|
||||
self.args.queue,
|
||||
]
|
||||
):
|
||||
raise InvalidArgumentError(
|
||||
"Either provide an `experiment` argument, or use the "
|
||||
"`--rev` or `--all-commits` flag."
|
||||
)
|
||||
|
||||
def run(self):
|
||||
|
||||
self.repo.experiments.remove(
|
||||
self.raise_error_if_all_disabled()
|
||||
|
||||
removed_list = self.repo.experiments.remove(
|
||||
exp_names=self.args.experiment,
|
||||
all_commits=self.args.all_commits,
|
||||
rev=self.args.rev,
|
||||
num=self.args.num,
|
||||
queue=self.args.queue,
|
||||
clear_all=self.args.all,
|
||||
remote=self.args.git_remote,
|
||||
git_remote=self.args.git_remote,
|
||||
)
|
||||
removed = ",".join(removed_list)
|
||||
ui.write(f"Removed experiments: {removed}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def add_parser(experiments_subparsers, parent_parser):
|
||||
from . import add_rev_selection_flags
|
||||
|
||||
EXPERIMENTS_REMOVE_HELP = "Remove experiments."
|
||||
experiments_remove_parser = experiments_subparsers.add_parser(
|
||||
@@ -31,15 +54,10 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
remove_group = experiments_remove_parser.add_mutually_exclusive_group()
|
||||
add_rev_selection_flags(experiments_remove_parser, "Remove", False)
|
||||
remove_group.add_argument(
|
||||
"--queue", action="store_true", help="Remove all queued experiments."
|
||||
)
|
||||
remove_group.add_argument(
|
||||
"-A",
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Remove all committed experiments.",
|
||||
)
|
||||
remove_group.add_argument(
|
||||
"-g",
|
||||
"--git-remote",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.commands.repro import CmdRepro
|
||||
from dvc.commands.repro import add_arguments as add_repro_arguments
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
@@ -39,7 +39,7 @@ class CmdExperimentsRun(CmdRepro):
|
||||
reset=self.args.reset,
|
||||
tmp_dir=self.args.tmp_dir,
|
||||
machine=self.args.machine,
|
||||
**self._repro_kwargs,
|
||||
**self._common_kwargs,
|
||||
)
|
||||
|
||||
if self.args.metrics and results:
|
||||
@@ -81,7 +81,7 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
|
||||
|
||||
def _add_run_common(parser):
|
||||
"""Add common args for 'exp run' and 'exp resume'."""
|
||||
"""Add common args for 'exp run'."""
|
||||
# inherit arguments from `dvc repro`
|
||||
add_repro_arguments(parser)
|
||||
parser.add_argument(
|
||||
|
||||
@@ -8,9 +8,9 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from funcy import lmap
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.commands.metrics import DEFAULT_PRECISION
|
||||
from dvc.exceptions import DvcException, InvalidArgumentError
|
||||
from dvc.ui import ui
|
||||
@@ -426,20 +426,22 @@ def show_experiments(
|
||||
subset=subset,
|
||||
)
|
||||
td.drop_duplicates("rows", subset=subset)
|
||||
td.column("Experiment")[:] = [
|
||||
# remove tree characters
|
||||
str(x).encode("ascii", "ignore").strip().decode()
|
||||
for x in td.column("Experiment")
|
||||
]
|
||||
if "Experiment" in td:
|
||||
td.column("Experiment")[:] = [
|
||||
# remove tree characters
|
||||
str(x).encode("ascii", "ignore").strip().decode()
|
||||
for x in td.column("Experiment")
|
||||
]
|
||||
out = kwargs.get("out") or "dvc_plots"
|
||||
output_file = os.path.join(out, "index.html")
|
||||
ui.write(
|
||||
td.to_parallel_coordinates(
|
||||
output_path=os.path.abspath(out),
|
||||
output_path=os.path.abspath(output_file),
|
||||
color_by=kwargs.get("sort_by") or "Experiment",
|
||||
)
|
||||
)
|
||||
if kwargs.get("open"):
|
||||
return ui.open_browser(os.path.join(out, "index.html"))
|
||||
return ui.open_browser(output_file)
|
||||
|
||||
else:
|
||||
td.render(
|
||||
@@ -478,6 +480,7 @@ class CmdExperimentsShow(CmdBase):
|
||||
num=self.args.num,
|
||||
sha_only=self.args.sha,
|
||||
param_deps=self.args.param_deps,
|
||||
fetch_running=self.args.fetch_running,
|
||||
)
|
||||
except DvcException:
|
||||
logger.exception("failed to show experiments")
|
||||
@@ -515,6 +518,8 @@ class CmdExperimentsShow(CmdBase):
|
||||
|
||||
|
||||
def add_parser(experiments_subparsers, parent_parser):
|
||||
from . import add_rev_selection_flags
|
||||
|
||||
EXPERIMENTS_SHOW_HELP = "Print experiments."
|
||||
experiments_show_parser = experiments_subparsers.add_parser(
|
||||
"show",
|
||||
@@ -523,6 +528,7 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
help=EXPERIMENTS_SHOW_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
add_rev_selection_flags(experiments_show_parser, "Show")
|
||||
experiments_show_parser.add_argument(
|
||||
"-a",
|
||||
"--all-branches",
|
||||
@@ -537,32 +543,6 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Show experiments derived from all Git tags.",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"-A",
|
||||
"--all-commits",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show experiments derived from all Git commits.",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--rev",
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"Show experiments derived from the specified revision. "
|
||||
"Defaults to HEAD if none of `--rev`,`-a`,`-A`,`-T` is specified."
|
||||
),
|
||||
metavar="<rev>",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"-n",
|
||||
"--num",
|
||||
type=int,
|
||||
default=1,
|
||||
dest="num",
|
||||
metavar="<num>",
|
||||
help="Show the last `num` commits from <rev>.",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--no-pager",
|
||||
action="store_true",
|
||||
@@ -666,4 +646,10 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Open the Parallel Coordinates Plot directly in the browser.",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--no-fetch",
|
||||
dest="fetch_running",
|
||||
action="store_false",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
experiments_show_parser.set_defaults(func=CmdExperimentsShow)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -137,7 +137,6 @@ def add_parser(subparsers, parent_parser):
|
||||
help=(
|
||||
"Number of jobs to run simultaneously. "
|
||||
"The default value is 4 * cpu_count(). "
|
||||
"For SSH remotes, the default is 4. "
|
||||
),
|
||||
metavar="<number>",
|
||||
)
|
||||
|
||||
+1
-2
@@ -1,11 +1,11 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBaseNoRepo
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
from ..cli.utils import append_doc_link
|
||||
from . import completion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -94,7 +94,6 @@ def add_parser(subparsers, parent_parser):
|
||||
help=(
|
||||
"Number of jobs to run simultaneously. "
|
||||
"The default value is 4 * cpu_count(). "
|
||||
"For SSH remotes, the default is 4. "
|
||||
),
|
||||
metavar="<number>",
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBaseNoRepo
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
from ..cli.utils import append_doc_link
|
||||
from . import completion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,7 +44,6 @@ def add_parser(subparsers, parent_parser):
|
||||
help=(
|
||||
"Number of jobs to run simultaneously. "
|
||||
"The default value is 4 * cpu_count(). "
|
||||
"For SSH remotes, the default is 4. "
|
||||
),
|
||||
metavar="<number>",
|
||||
)
|
||||
|
||||
+1
-2
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -91,7 +91,6 @@ def add_parser(subparsers, parent_parser):
|
||||
help=(
|
||||
"Number of jobs to run simultaneously. "
|
||||
"The default value is 4 * cpu_count(). "
|
||||
"For SSH remotes, the default is 4. "
|
||||
),
|
||||
metavar="<number>",
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -91,7 +91,6 @@ def add_parser(subparsers, parent_parser):
|
||||
help=(
|
||||
"Number of jobs to run simultaneously. "
|
||||
"The default value is 4 * cpu_count(). "
|
||||
"For SSH remotes, the default is 4. "
|
||||
),
|
||||
metavar="<number>",
|
||||
)
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import argparse
|
||||
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import fix_subparsers
|
||||
from dvc.commands import completion
|
||||
from dvc.ui import ui
|
||||
|
||||
|
||||
class CmdLive(CmdBase):
|
||||
UNINITIALIZED = True
|
||||
|
||||
def _run(self, target, revs=None):
|
||||
from dvc.render.utils import match_renderers, render
|
||||
|
||||
metrics, plots = self.repo.live.show(target=target, revs=revs)
|
||||
|
||||
if plots:
|
||||
from pathlib import Path
|
||||
|
||||
html_path = Path.cwd() / (self.args.target + "_html")
|
||||
|
||||
renderers = match_renderers(plots, self.repo.plots.templates)
|
||||
index_path = render(self.repo, renderers, metrics, html_path)
|
||||
ui.write(index_path.as_uri())
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
class CmdLiveShow(CmdLive):
|
||||
def run(self):
|
||||
return self._run(self.args.target)
|
||||
|
||||
|
||||
class CmdLiveDiff(CmdLive):
|
||||
def run(self):
|
||||
return self._run(self.args.target, self.args.revs)
|
||||
|
||||
|
||||
def shared_parent_parser():
|
||||
parent_parser = argparse.ArgumentParser(add_help=False)
|
||||
parent_parser.add_argument(
|
||||
"target", help="Logs dir to produce summary from"
|
||||
).complete = completion.DIR
|
||||
parent_parser.add_argument(
|
||||
"-o",
|
||||
"--out",
|
||||
default=None,
|
||||
help="Destination path to save plots to",
|
||||
metavar="<path>",
|
||||
).complete = completion.DIR
|
||||
return parent_parser
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
LIVE_DESCRIPTION = (
|
||||
"Commands to visualize and compare dvclive-produced logs."
|
||||
)
|
||||
live_parser = subparsers.add_parser(
|
||||
"live",
|
||||
parents=[parent_parser],
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
description=LIVE_DESCRIPTION,
|
||||
)
|
||||
live_subparsers = live_parser.add_subparsers(
|
||||
dest="cmd",
|
||||
help="Use `dvc live CMD --help` to display command-specific help.",
|
||||
)
|
||||
|
||||
fix_subparsers(live_subparsers)
|
||||
|
||||
SHOW_HELP = "Visualize dvclive directory content."
|
||||
live_show_parser = live_subparsers.add_parser(
|
||||
"show",
|
||||
parents=[parent_parser, shared_parent_parser()],
|
||||
help=SHOW_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
live_show_parser.set_defaults(func=CmdLiveShow)
|
||||
|
||||
DIFF_HELP = (
|
||||
"Show multiple versions of dvclive data, "
|
||||
"by plotting it in single view."
|
||||
)
|
||||
live_diff_parser = live_subparsers.add_parser(
|
||||
"diff",
|
||||
parents=[parent_parser, shared_parent_parser()],
|
||||
help=DIFF_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
live_diff_parser.add_argument(
|
||||
"--revs",
|
||||
nargs="*",
|
||||
default=None,
|
||||
help="Git revision (e.g. SHA, branch, tag)",
|
||||
metavar="<commit>",
|
||||
)
|
||||
live_diff_parser.set_defaults(func=CmdLiveDiff)
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBaseNoRepo
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.commands.ls.ls_colors import LsColors
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.ui import ui
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link, fix_subparsers
|
||||
from dvc.commands import completion
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.ui import ui
|
||||
from dvc.utils.serialize import encode_exception
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link, fix_subparsers
|
||||
from dvc.commands import completion
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.ui import ui
|
||||
|
||||
|
||||
+45
-28
@@ -1,12 +1,13 @@
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from funcy import first
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link, fix_subparsers
|
||||
from dvc.commands import completion
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.ui import ui
|
||||
from dvc.utils import format_link
|
||||
@@ -14,16 +15,13 @@ from dvc.utils import format_link
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _show_json(renderers, path: None):
|
||||
if any(r.needs_output_path for r in renderers) and not path:
|
||||
raise DvcException("Output path ('-o') is required!")
|
||||
def _show_json(renderers, split=False):
|
||||
from dvc.render.convert import to_json
|
||||
|
||||
result = {
|
||||
renderer.filename: json.loads(renderer.as_json(path=path))
|
||||
for renderer in renderers
|
||||
renderer.name: to_json(renderer, split) for renderer in renderers
|
||||
}
|
||||
if result:
|
||||
ui.write_json(result)
|
||||
ui.write_json(result)
|
||||
|
||||
|
||||
class CmdPlots(CmdBase):
|
||||
@@ -40,8 +38,8 @@ class CmdPlots(CmdBase):
|
||||
def run(self):
|
||||
from pathlib import Path
|
||||
|
||||
from dvc.render.utils import match_renderers, render
|
||||
from dvc.render.vega import VegaRenderer
|
||||
from dvc.render.match import match_renderers
|
||||
from dvc_render import render_html
|
||||
|
||||
if self.args.show_vega:
|
||||
if not self.args.targets:
|
||||
@@ -71,31 +69,47 @@ class CmdPlots(CmdBase):
|
||||
"visualization file will not be created."
|
||||
)
|
||||
|
||||
out: str = self.args.out or "dvc_plots"
|
||||
|
||||
renderers_out = (
|
||||
out if self.args.json else os.path.join(out, "static")
|
||||
)
|
||||
renderers = match_renderers(
|
||||
plots_data=plots_data, templates=self.repo.plots.templates
|
||||
plots_data=plots_data,
|
||||
out=renderers_out,
|
||||
templates_dir=self.repo.plots.templates_dir,
|
||||
)
|
||||
|
||||
if self.args.show_vega:
|
||||
renderer = first(
|
||||
filter(lambda r: isinstance(r, VegaRenderer), renderers)
|
||||
)
|
||||
renderer = first(filter(lambda r: r.TYPE == "vega", renderers))
|
||||
if renderer:
|
||||
ui.write_json(renderer.asdict())
|
||||
ui.write_json(json.loads(renderer.get_filled_template()))
|
||||
return 0
|
||||
if self.args.json:
|
||||
_show_json(renderers, self.args.out)
|
||||
_show_json(renderers, self.args.split)
|
||||
return 0
|
||||
|
||||
rel: str = self.args.out or "dvc_plots"
|
||||
path: Path = (Path.cwd() / rel).resolve()
|
||||
index_path = render(
|
||||
self.repo,
|
||||
renderers,
|
||||
path=path,
|
||||
html_template_path=self.args.html_template,
|
||||
html_template_path = self.args.html_template
|
||||
if not html_template_path:
|
||||
html_template_path = self.repo.config.get("plots", {}).get(
|
||||
"html_template", None
|
||||
)
|
||||
if html_template_path and not os.path.isabs(
|
||||
html_template_path
|
||||
):
|
||||
html_template_path = os.path.join(
|
||||
self.repo.dvc_dir, html_template_path
|
||||
)
|
||||
|
||||
output_file: Path = (Path.cwd() / out).resolve() / "index.html"
|
||||
|
||||
render_html(
|
||||
renderers=renderers,
|
||||
output_file=output_file,
|
||||
template_path=html_template_path,
|
||||
)
|
||||
|
||||
ui.write(index_path.as_uri())
|
||||
ui.write(output_file.as_uri())
|
||||
auto_open = self.repo.config["plots"].get("auto_open", False)
|
||||
if self.args.open or auto_open:
|
||||
if not auto_open:
|
||||
@@ -104,7 +118,7 @@ class CmdPlots(CmdBase):
|
||||
"\n"
|
||||
"\tdvc config plots.auto_open true"
|
||||
)
|
||||
return ui.open_browser(index_path)
|
||||
return ui.open_browser(output_file)
|
||||
|
||||
return 0
|
||||
|
||||
@@ -151,17 +165,17 @@ class CmdPlotsTemplates(CmdBase):
|
||||
]
|
||||
|
||||
def run(self):
|
||||
import os
|
||||
from dvc_render.vega_templates import dump_templates
|
||||
|
||||
try:
|
||||
out = (
|
||||
os.path.join(os.getcwd(), self.args.out)
|
||||
if self.args.out
|
||||
else self.repo.plots.templates.templates_dir
|
||||
else self.repo.plots.templates_dir
|
||||
)
|
||||
|
||||
targets = [self.args.target] if self.args.target else None
|
||||
self.repo.plots.templates.init(output=out, targets=targets)
|
||||
dump_templates(output=out, targets=targets)
|
||||
templates_path = os.path.relpath(out, os.getcwd())
|
||||
ui.write(f"Templates have been written into '{templates_path}'.")
|
||||
|
||||
@@ -354,6 +368,9 @@ def _add_ui_arguments(parser):
|
||||
default=False,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--split", action="store_true", default=False, help=argparse.SUPPRESS
|
||||
)
|
||||
parser.add_argument(
|
||||
"--open",
|
||||
action="store_true",
|
||||
|
||||
@@ -113,8 +113,8 @@ class CmdRemoteDefault(CmdRemote):
|
||||
class CmdRemoteList(CmdRemote):
|
||||
def run(self):
|
||||
conf = self.config.read(self.args.level)
|
||||
for name, conf in conf["remote"].items():
|
||||
ui.write(name, conf["url"], sep="\t")
|
||||
for name, remote_conf in conf["remote"].items():
|
||||
ui.write(name, remote_conf["url"], sep="\t")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
+54
-45
@@ -1,8 +1,8 @@
|
||||
import argparse
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.commands.status import CmdDataStatus
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ class CmdRepro(CmdBase):
|
||||
def run(self):
|
||||
from dvc.ui import ui
|
||||
|
||||
stages = self.repo.reproduce(**self._repro_kwargs)
|
||||
stages = self.repo.reproduce(
|
||||
**self._common_kwargs, **self._repro_kwargs
|
||||
)
|
||||
if len(stages) == 0:
|
||||
ui.write(CmdDataStatus.UP_TO_DATE_MSG)
|
||||
else:
|
||||
@@ -27,7 +29,7 @@ class CmdRepro(CmdBase):
|
||||
return 0
|
||||
|
||||
@property
|
||||
def _repro_kwargs(self):
|
||||
def _common_kwargs(self):
|
||||
return {
|
||||
"targets": self.args.targets,
|
||||
"single_item": self.args.single_item,
|
||||
@@ -36,12 +38,17 @@ class CmdRepro(CmdBase):
|
||||
"interactive": self.args.interactive,
|
||||
"pipeline": self.args.pipeline,
|
||||
"all_pipelines": self.args.all_pipelines,
|
||||
"run_cache": not self.args.no_run_cache,
|
||||
"no_commit": self.args.no_commit,
|
||||
"downstream": self.args.downstream,
|
||||
"recursive": self.args.recursive,
|
||||
"force_downstream": self.args.force_downstream,
|
||||
"pull": self.args.pull,
|
||||
}
|
||||
|
||||
@property
|
||||
def _repro_kwargs(self):
|
||||
return {
|
||||
"run_cache": not self.args.no_run_cache,
|
||||
"no_commit": self.args.no_commit,
|
||||
"glob": self.args.glob,
|
||||
}
|
||||
|
||||
@@ -59,6 +66,13 @@ def add_arguments(repro_parser):
|
||||
default=False,
|
||||
help="Reproduce even if dependencies were not changed.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"-i",
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ask for confirmation before reproducing each stage.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"-s",
|
||||
"--single-item",
|
||||
@@ -67,27 +81,6 @@ def add_arguments(repro_parser):
|
||||
help="Reproduce only single data item without recursive dependencies "
|
||||
"check.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"-m",
|
||||
"--metrics",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show metrics after reproduction.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--dry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only print the commands that would be executed without "
|
||||
"actually executing.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"-i",
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ask for confirmation before reproducing each stage.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"-p",
|
||||
"--pipeline",
|
||||
@@ -111,13 +104,17 @@ def add_arguments(repro_parser):
|
||||
help="Reproduce all stages in the specified directory.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--no-run-cache",
|
||||
"-m",
|
||||
"--metrics",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Execute stage commands even if they have already been run with "
|
||||
"the same command/dependencies/outputs/etc before."
|
||||
),
|
||||
help="Show metrics after reproduction.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--downstream",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Start from the specified stages when reproducing pipelines.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--force-downstream",
|
||||
@@ -126,18 +123,6 @@ def add_arguments(repro_parser):
|
||||
help="Reproduce all descendants of a changed stage even if their "
|
||||
"direct dependencies didn't change.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--no-commit",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Don't put files/directories into cache.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--downstream",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Start from the specified stages when reproducing pipelines.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--pull",
|
||||
action="store_true",
|
||||
@@ -148,10 +133,11 @@ def add_arguments(repro_parser):
|
||||
),
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--glob",
|
||||
"--dry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Allows targets containing shell-style wildcards.",
|
||||
help="Only print the commands that would be executed without "
|
||||
"actually executing.",
|
||||
)
|
||||
|
||||
|
||||
@@ -166,5 +152,28 @@ def add_parser(subparsers, parent_parser):
|
||||
help=REPRO_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
# repro/exp run shared args
|
||||
add_arguments(repro_parser)
|
||||
# repro only args
|
||||
repro_parser.add_argument(
|
||||
"--glob",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Allows targets containing shell-style wildcards.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--no-commit",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Don't put files/directories into cache.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--no-run-cache",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Execute stage commands even if they have already been run with "
|
||||
"the same command/dependencies/outputs/etc before."
|
||||
),
|
||||
)
|
||||
repro_parser.set_defaults(func=CmdRepro)
|
||||
|
||||
+13
-13
@@ -69,12 +69,25 @@ def add_parser(subparsers, parent_parser):
|
||||
run_parser.add_argument(
|
||||
"--file", metavar="<filename>", help=argparse.SUPPRESS
|
||||
)
|
||||
run_parser.add_argument(
|
||||
"--single-stage",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
_add_common_args(run_parser)
|
||||
run_parser.add_argument(
|
||||
"--no-exec",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only create dvc.yaml without actually running it.",
|
||||
)
|
||||
run_parser.add_argument(
|
||||
"--no-commit",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Don't put files/directories into cache.",
|
||||
)
|
||||
run_parser.add_argument(
|
||||
"--no-run-cache",
|
||||
action="store_true",
|
||||
@@ -84,17 +97,4 @@ def add_parser(subparsers, parent_parser):
|
||||
"with the same command/dependencies/outputs/etc before."
|
||||
),
|
||||
)
|
||||
run_parser.add_argument(
|
||||
"--no-commit",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Don't put files/directories into cache.",
|
||||
)
|
||||
run_parser.add_argument(
|
||||
"--single-stage",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
_add_common_args(run_parser)
|
||||
run_parser.set_defaults(func=CmdRun)
|
||||
|
||||
+46
-47
@@ -3,9 +3,9 @@ import logging
|
||||
from itertools import chain, filterfalse
|
||||
from typing import TYPE_CHECKING, Dict, Iterable, List
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link, fix_subparsers
|
||||
from dvc.commands import completion
|
||||
from dvc.utils.cli_parse import parse_params
|
||||
from dvc.utils.humanize import truncate_text
|
||||
|
||||
@@ -94,9 +94,7 @@ class CmdStageList(CmdBase):
|
||||
self.repo.stage_collection_error_handler = log_error
|
||||
|
||||
stages = self._get_stages()
|
||||
names_only = self.args.names_only
|
||||
|
||||
data = prepare_stages_data(stages, description=not names_only)
|
||||
data = prepare_stages_data(stages, description=not self.args.name_only)
|
||||
ui.table(data.items())
|
||||
|
||||
return 0
|
||||
@@ -139,6 +137,13 @@ class CmdStageAdd(CmdBase):
|
||||
|
||||
|
||||
def _add_common_args(parser):
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Overwrite existing stage",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--deps",
|
||||
@@ -147,6 +152,14 @@ def _add_common_args(parser):
|
||||
help="Declare dependencies for reproducible cmd.",
|
||||
metavar="<path>",
|
||||
).complete = completion.FILE
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--params",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare parameter to use as additional dependency.",
|
||||
metavar="[<filename>:]<params_list>",
|
||||
).complete = completion.FILE
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--outs",
|
||||
@@ -165,13 +178,36 @@ def _add_common_args(parser):
|
||||
metavar="<filename>",
|
||||
).complete = completion.FILE
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--params",
|
||||
"-c",
|
||||
"--checkpoints",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare parameter to use as additional dependency.",
|
||||
metavar="[<filename>:]<params_list>",
|
||||
help="Declare checkpoint output file or directory for 'dvc exp run'. "
|
||||
"Not compatible with 'dvc repro'.",
|
||||
metavar="<filename>",
|
||||
).complete = completion.FILE
|
||||
parser.add_argument(
|
||||
"--external",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Allow outputs that are outside of the DVC repository.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--outs-persist",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare output file or directory that will not be "
|
||||
"removed upon repro.",
|
||||
metavar="<filename>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--outs-persist-no-cache",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare output file or directory that will not be "
|
||||
"removed upon repro (do not put into DVC cache).",
|
||||
metavar="<filename>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--metrics",
|
||||
@@ -214,7 +250,7 @@ def _add_common_args(parser):
|
||||
"--live-no-summary",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Signal dvclive logger to not dump latest metrics file.",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--live-no-html",
|
||||
@@ -228,50 +264,12 @@ def _add_common_args(parser):
|
||||
help="Directory within your repo to run your command in.",
|
||||
metavar="<path>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Overwrite existing stage",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--outs-persist",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare output file or directory that will not be "
|
||||
"removed upon repro.",
|
||||
metavar="<filename>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--outs-persist-no-cache",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare output file or directory that will not be "
|
||||
"removed upon repro (do not put into DVC cache).",
|
||||
metavar="<filename>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--checkpoints",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare checkpoint output file or directory for 'dvc exp run'. "
|
||||
"Not compatible with 'dvc repro'.",
|
||||
metavar="<filename>",
|
||||
).complete = completion.FILE
|
||||
parser.add_argument(
|
||||
"--always-changed",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Always consider this DVC-file as changed.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--external",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Allow outputs that are outside of the DVC repository.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--desc",
|
||||
type=str,
|
||||
@@ -358,6 +356,7 @@ def add_parser(subparsers, parent_parser):
|
||||
help="List all stages inside the specified directory.",
|
||||
)
|
||||
stage_list_parser.add_argument(
|
||||
"--name-only",
|
||||
"--names-only",
|
||||
action="store_true",
|
||||
default=False,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli import completion
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.commands import completion
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -71,7 +71,6 @@ def add_parser(subparsers, parent_parser):
|
||||
help=(
|
||||
"Number of jobs to run simultaneously. "
|
||||
"The default value is 4 * cpu_count(). "
|
||||
"For SSH remotes, the default is 4. "
|
||||
),
|
||||
metavar="<number>",
|
||||
)
|
||||
|
||||
+11
-7
@@ -168,17 +168,21 @@ class TabularData(MutableSequence[Sequence["CellT"]]):
|
||||
|
||||
def to_parallel_coordinates(
|
||||
self, output_path: "StrPath", color_by: str = None
|
||||
) -> str:
|
||||
from dvc.render.html import write
|
||||
from dvc.render.plotly import ParallelCoordinatesRenderer
|
||||
) -> "StrPath":
|
||||
from dvc_render.html import render_html
|
||||
from dvc_render.plotly import ParallelCoordinatesRenderer
|
||||
|
||||
index_path = write(
|
||||
output_path,
|
||||
render_html(
|
||||
renderers=[
|
||||
ParallelCoordinatesRenderer(self, color_by, self._fill_value)
|
||||
ParallelCoordinatesRenderer(
|
||||
self.as_dict(),
|
||||
color_by=color_by,
|
||||
fill_value=self._fill_value,
|
||||
)
|
||||
],
|
||||
output_file=output_path,
|
||||
)
|
||||
return index_path.as_uri()
|
||||
return output_path
|
||||
|
||||
def add_column(self, name: str) -> None:
|
||||
self._columns[name] = Column([self._fill_value] * len(self))
|
||||
|
||||
+7
-8
@@ -86,9 +86,11 @@ class Config(dict):
|
||||
def __init__(
|
||||
self, dvc_dir=None, validate=True, fs=None, config=None
|
||||
): # pylint: disable=super-init-not-called
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
from dvc.fs import LocalFileSystem
|
||||
|
||||
self.dvc_dir = dvc_dir
|
||||
self.wfs = LocalFileSystem()
|
||||
self.fs = fs or self.wfs
|
||||
|
||||
if not dvc_dir:
|
||||
try:
|
||||
@@ -98,10 +100,7 @@ class Config(dict):
|
||||
except NotDvcRepoError:
|
||||
self.dvc_dir = None
|
||||
else:
|
||||
self.dvc_dir = os.path.abspath(os.path.realpath(dvc_dir))
|
||||
|
||||
self.wfs = LocalFileSystem(url=self.dvc_dir)
|
||||
self.fs = fs or self.wfs
|
||||
self.dvc_dir = self.fs.path.abspath(self.fs.path.realpath(dvc_dir))
|
||||
|
||||
self.load(validate=validate, config=config)
|
||||
|
||||
@@ -124,8 +123,8 @@ class Config(dict):
|
||||
}
|
||||
|
||||
if self.dvc_dir is not None:
|
||||
files["repo"] = os.path.join(self.dvc_dir, self.CONFIG)
|
||||
files["local"] = os.path.join(self.dvc_dir, self.CONFIG_LOCAL)
|
||||
files["repo"] = self.fs.path.join(self.dvc_dir, self.CONFIG)
|
||||
files["local"] = self.fs.path.join(self.dvc_dir, self.CONFIG_LOCAL)
|
||||
|
||||
return files
|
||||
|
||||
@@ -228,7 +227,7 @@ class Config(dict):
|
||||
|
||||
@staticmethod
|
||||
def _to_relpath(conf_dir, path):
|
||||
from dvc.fs.local import localfs
|
||||
from dvc.fs import localfs
|
||||
from dvc.utils import relpath
|
||||
|
||||
from .config_schema import RelPath
|
||||
|
||||
+3
-2
@@ -106,8 +106,9 @@ def daemon(args):
|
||||
cmd = ["daemon", "-q"] + args
|
||||
|
||||
env = fix_env()
|
||||
file_path = os.path.abspath(inspect.stack()[0][1])
|
||||
env["PYTHONPATH"] = os.path.dirname(os.path.dirname(file_path))
|
||||
if not is_binary():
|
||||
file_path = os.path.abspath(inspect.stack()[0][1])
|
||||
env["PYTHONPATH"] = os.path.dirname(os.path.dirname(file_path))
|
||||
env[DVC_DAEMON] = "1"
|
||||
|
||||
_spawn(cmd, env)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Iterator, Union
|
||||
|
||||
from .tree import Tree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check(odb: "ObjectDB", obj: "HashFile", **kwargs):
|
||||
if isinstance(obj, Tree):
|
||||
for _, _, oid in obj:
|
||||
odb.check(oid, **kwargs)
|
||||
|
||||
odb.check(obj.hash_info, **kwargs)
|
||||
|
||||
|
||||
def load(odb: "ObjectDB", hash_info: "HashInfo") -> "HashFile":
|
||||
if hash_info.isdir:
|
||||
return Tree.load(odb, hash_info)
|
||||
return odb.get(hash_info)
|
||||
|
||||
|
||||
def iterobjs(
|
||||
obj: Union["Tree", "HashFile"]
|
||||
) -> Iterator[Union["Tree", "HashFile"]]:
|
||||
if isinstance(obj, Tree):
|
||||
yield from (entry_obj for _, entry_obj in obj)
|
||||
yield obj
|
||||
@@ -1,265 +0,0 @@
|
||||
import logging
|
||||
from itertools import chain
|
||||
|
||||
from dvc import prompt
|
||||
from dvc.exceptions import CacheLinkError, CheckoutError, ConfirmRemoveError
|
||||
from dvc.fs.utils import test_links, transfer
|
||||
from dvc.ignore import DvcIgnoreFilter
|
||||
from dvc.types import Optional
|
||||
|
||||
from .diff import ROOT
|
||||
from .diff import diff as odiff
|
||||
from .slow_link_detection import slow_link_guard # type: ignore[attr-defined]
|
||||
from .stage import stage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _remove(fs_path, fs, in_cache, force=False):
|
||||
if not fs.exists(fs_path):
|
||||
return
|
||||
|
||||
if force:
|
||||
fs.remove(fs_path)
|
||||
return
|
||||
|
||||
if not in_cache:
|
||||
msg = (
|
||||
f"file/directory '{fs_path}' is going to be removed. "
|
||||
"Are you sure you want to proceed?"
|
||||
)
|
||||
|
||||
if not prompt.confirm(msg):
|
||||
raise ConfirmRemoveError(fs_path)
|
||||
|
||||
fs.remove(fs_path)
|
||||
|
||||
|
||||
def _relink(link, cache, cache_info, fs, path, in_cache, force):
|
||||
_remove(path, fs, in_cache, force=force)
|
||||
link(cache, cache_info, fs, path)
|
||||
# NOTE: Depending on a file system (e.g. on NTFS), `_remove` might reset
|
||||
# read-only permissions in order to delete a hardlink to protected object,
|
||||
# which will also reset it for the object itself, making it unprotected,
|
||||
# so we need to protect it back.
|
||||
cache.protect(cache_info)
|
||||
|
||||
|
||||
def _checkout_file(
|
||||
link,
|
||||
fs_path,
|
||||
fs,
|
||||
change,
|
||||
cache,
|
||||
force,
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
state=None,
|
||||
):
|
||||
"""The file is changed we need to checkout a new copy"""
|
||||
modified = False
|
||||
|
||||
cache_fs_path = cache.hash_to_path(change.new.oid.value)
|
||||
if change.old.oid:
|
||||
if relink:
|
||||
if fs.iscopy(fs_path) and cache.cache_types[0] == "copy":
|
||||
cache.unprotect(fs_path)
|
||||
else:
|
||||
_relink(
|
||||
link,
|
||||
cache,
|
||||
cache_fs_path,
|
||||
fs,
|
||||
fs_path,
|
||||
change.old.in_cache,
|
||||
force=force,
|
||||
)
|
||||
else:
|
||||
modified = True
|
||||
_relink(
|
||||
link,
|
||||
cache,
|
||||
cache_fs_path,
|
||||
fs,
|
||||
fs_path,
|
||||
change.old.in_cache,
|
||||
force=force,
|
||||
)
|
||||
else:
|
||||
link(cache, cache_fs_path, fs, fs_path)
|
||||
modified = True
|
||||
|
||||
if state:
|
||||
state.save(fs_path, fs, change.new.oid)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(fs_path)
|
||||
|
||||
return modified
|
||||
|
||||
|
||||
def _diff(
|
||||
fs_path,
|
||||
fs,
|
||||
obj,
|
||||
cache,
|
||||
relink=False,
|
||||
dvcignore: Optional[DvcIgnoreFilter] = None,
|
||||
):
|
||||
old = None
|
||||
try:
|
||||
_, _, old = stage(
|
||||
cache,
|
||||
fs_path,
|
||||
fs,
|
||||
obj.hash_info.name if obj else cache.fs.PARAM_CHECKSUM,
|
||||
dry_run=True,
|
||||
dvcignore=dvcignore,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
diff = odiff(old, obj, cache)
|
||||
|
||||
if relink:
|
||||
diff.modified.extend(diff.unchanged)
|
||||
|
||||
return diff
|
||||
|
||||
|
||||
class Link:
|
||||
def __init__(self, links):
|
||||
self._links = links
|
||||
|
||||
@slow_link_guard
|
||||
def __call__(self, cache, from_path, to_fs, to_path):
|
||||
if to_fs.exists(to_path):
|
||||
to_fs.remove(to_path) # broken symlink
|
||||
|
||||
cache.makedirs(cache.fs.path.parent(to_path))
|
||||
try:
|
||||
transfer(cache.fs, from_path, to_fs, to_path, links=self._links)
|
||||
except FileNotFoundError as exc:
|
||||
raise CheckoutError([to_path]) from exc
|
||||
except OSError as exc:
|
||||
raise CacheLinkError([to_path]) from exc
|
||||
|
||||
|
||||
def _checkout(
|
||||
diff,
|
||||
fs_path,
|
||||
fs,
|
||||
cache,
|
||||
force=False,
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
state=None,
|
||||
):
|
||||
if not diff:
|
||||
return
|
||||
|
||||
links = test_links(cache.cache_types, cache.fs, cache.fs_path, fs, fs_path)
|
||||
if not links:
|
||||
raise CacheLinkError([fs_path])
|
||||
link = Link(links)
|
||||
|
||||
for change in diff.deleted:
|
||||
entry_path = (
|
||||
fs.path.join(fs_path, *change.old.key)
|
||||
if change.old.key != ROOT
|
||||
else fs_path
|
||||
)
|
||||
_remove(entry_path, fs, change.old.in_cache, force=force)
|
||||
|
||||
failed = []
|
||||
for change in chain(diff.added, diff.modified):
|
||||
entry_path = (
|
||||
fs.path.join(fs_path, *change.new.key)
|
||||
if change.new.key != ROOT
|
||||
else fs_path
|
||||
)
|
||||
if change.new.oid.isdir:
|
||||
fs.makedirs(entry_path)
|
||||
continue
|
||||
|
||||
try:
|
||||
_checkout_file(
|
||||
link,
|
||||
entry_path,
|
||||
fs,
|
||||
change,
|
||||
cache,
|
||||
force,
|
||||
progress_callback,
|
||||
relink,
|
||||
state=state,
|
||||
)
|
||||
except CheckoutError as exc:
|
||||
failed.extend(exc.target_infos)
|
||||
|
||||
if failed:
|
||||
raise CheckoutError(failed)
|
||||
|
||||
|
||||
def checkout(
|
||||
fs_path,
|
||||
fs,
|
||||
obj,
|
||||
cache,
|
||||
force=False,
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
quiet=False,
|
||||
dvcignore: Optional[DvcIgnoreFilter] = None,
|
||||
state=None,
|
||||
):
|
||||
# if scheme(fs_path) not in ["local", cache.fs.scheme]:
|
||||
# raise NotImplementedError
|
||||
|
||||
diff = _diff(
|
||||
fs_path,
|
||||
fs,
|
||||
obj,
|
||||
cache,
|
||||
relink=relink,
|
||||
dvcignore=dvcignore,
|
||||
)
|
||||
|
||||
failed = []
|
||||
if not obj:
|
||||
if not quiet:
|
||||
logger.warning(
|
||||
"No file hash info found for '%s'. It won't be created.",
|
||||
fs_path,
|
||||
)
|
||||
failed.append(fs_path)
|
||||
elif not diff:
|
||||
logger.trace("Data '%s' didn't change.", fs_path) # type: ignore
|
||||
|
||||
try:
|
||||
_checkout(
|
||||
diff,
|
||||
fs_path,
|
||||
fs,
|
||||
cache,
|
||||
force=force,
|
||||
progress_callback=progress_callback,
|
||||
relink=relink,
|
||||
state=state,
|
||||
)
|
||||
except CheckoutError as exc:
|
||||
failed.extend(exc.target_infos)
|
||||
|
||||
if diff and state:
|
||||
state.save_link(fs_path, fs)
|
||||
if not failed:
|
||||
state.save(fs_path, fs, obj.hash_info)
|
||||
|
||||
if failed or not diff:
|
||||
if progress_callback and obj:
|
||||
progress_callback(fs_path, len(obj))
|
||||
if failed:
|
||||
raise CheckoutError(failed)
|
||||
return
|
||||
|
||||
return bool(diff) and not relink
|
||||
@@ -1,149 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Iterable, Set
|
||||
|
||||
from dvc.objects.errors import ObjectDBError
|
||||
from dvc.utils.decorators import with_diskcache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.types import StrPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ObjectDBIndexBase(ABC):
|
||||
@abstractmethod
|
||||
def __init__(
|
||||
self,
|
||||
tmp_dir: "StrPath",
|
||||
name: str,
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __iter__(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __contains__(self, hash_):
|
||||
pass
|
||||
|
||||
def hashes(self):
|
||||
return iter(self)
|
||||
|
||||
@abstractmethod
|
||||
def dir_hashes(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def clear(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update(self, dir_hashes: Iterable[str], file_hashes: Iterable[str]):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def intersection(self, hashes: Set[str]):
|
||||
pass
|
||||
|
||||
|
||||
class ObjectDBIndexNoop(ObjectDBIndexBase):
|
||||
"""No-op class for ODBs which are not indexed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tmp_dir: "StrPath",
|
||||
name: str,
|
||||
): # pylint: disable=super-init-not-called
|
||||
pass
|
||||
|
||||
def __iter__(self):
|
||||
return iter([])
|
||||
|
||||
def __contains__(self, hash_):
|
||||
return False
|
||||
|
||||
def dir_hashes(self):
|
||||
return []
|
||||
|
||||
def clear(self):
|
||||
pass
|
||||
|
||||
def update(self, dir_hashes: Iterable[str], file_hashes: Iterable[str]):
|
||||
pass
|
||||
|
||||
def intersection(self, hashes: Set[str]):
|
||||
return []
|
||||
|
||||
|
||||
class ObjectDBIndex(ObjectDBIndexBase):
|
||||
"""Class for indexing hashes in an ODB."""
|
||||
|
||||
INDEX_SUFFIX = ".idx"
|
||||
INDEX_DIR = "index"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tmp_dir: "StrPath",
|
||||
name: str,
|
||||
): # pylint: disable=super-init-not-called
|
||||
from diskcache import Cache, Index
|
||||
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
from dvc.utils.fs import makedirs
|
||||
|
||||
self.index_dir = os.path.join(tmp_dir, self.INDEX_DIR, name)
|
||||
makedirs(self.index_dir, exist_ok=True)
|
||||
self.fs = LocalFileSystem()
|
||||
self.index = Index.fromcache(
|
||||
Cache(
|
||||
self.index_dir,
|
||||
disk_pickle_protocol=4,
|
||||
eviction_policy="none",
|
||||
)
|
||||
)
|
||||
|
||||
@with_diskcache(name="index")
|
||||
def __iter__(self):
|
||||
return iter(self.index)
|
||||
|
||||
@with_diskcache(name="index")
|
||||
def __contains__(self, hash_):
|
||||
return hash_ in self.index
|
||||
|
||||
@with_diskcache(name="index")
|
||||
def dir_hashes(self):
|
||||
"""Iterate over .dir hashes stored in the index."""
|
||||
yield from (hash_ for hash_, is_dir in self.index.items() if is_dir)
|
||||
|
||||
@with_diskcache(name="index")
|
||||
def clear(self):
|
||||
"""Clear this index (to force re-indexing later)."""
|
||||
from diskcache import Timeout
|
||||
|
||||
try:
|
||||
self.index.clear()
|
||||
except Timeout as exc:
|
||||
raise ObjectDBError("Failed to clear ODB index") from exc
|
||||
|
||||
@with_diskcache(name="index")
|
||||
def update(self, dir_hashes: Iterable[str], file_hashes: Iterable[str]):
|
||||
"""Update this index, adding the specified hashes."""
|
||||
from diskcache import Timeout
|
||||
|
||||
try:
|
||||
with self.index.transact():
|
||||
for hash_ in dir_hashes:
|
||||
self.index[hash_] = True
|
||||
with self.index.transact():
|
||||
for hash_ in file_hashes:
|
||||
self.index[hash_] = False
|
||||
except Timeout as exc:
|
||||
raise ObjectDBError("Failed to update ODB index") from exc
|
||||
|
||||
@with_diskcache(name="index")
|
||||
def intersection(self, hashes: Set[str]):
|
||||
"""Iterate over values from `hashes` which exist in the index."""
|
||||
yield from hashes.intersection(self.index.keys())
|
||||
@@ -1,174 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
|
||||
from funcy import cached_property
|
||||
from shortuuid import uuid
|
||||
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils.fs import copyfile, remove, umask, walk_files
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalObjectDB(ObjectDB):
|
||||
DEFAULT_CACHE_TYPES = ["reflink", "copy"]
|
||||
CACHE_MODE = 0o444
|
||||
UNPACKED_DIR_SUFFIX = ".unpacked"
|
||||
|
||||
def __init__(self, fs, fs_path, **config):
|
||||
super().__init__(fs, fs_path, **config)
|
||||
self.cache_dir = fs_path
|
||||
|
||||
shared = config.get("shared")
|
||||
if shared:
|
||||
self._file_mode = 0o664
|
||||
self._dir_mode = 0o2775
|
||||
else:
|
||||
self._file_mode = 0o666 & ~umask
|
||||
self._dir_mode = 0o777 & ~umask
|
||||
|
||||
@property
|
||||
def cache_dir(self):
|
||||
return self.fs_path if self.fs_path else None
|
||||
|
||||
@cache_dir.setter
|
||||
def cache_dir(self, value):
|
||||
self.fs_path = value
|
||||
|
||||
@cached_property
|
||||
def cache_path(self):
|
||||
return os.path.abspath(self.cache_dir)
|
||||
|
||||
def move(self, from_info, to_info):
|
||||
super().move(from_info, to_info)
|
||||
os.chmod(to_info, self._file_mode)
|
||||
|
||||
def makedirs(self, fs_path):
|
||||
from dvc.utils.fs import makedirs
|
||||
|
||||
makedirs(fs_path, exist_ok=True, mode=self._dir_mode)
|
||||
|
||||
def hash_to_path(self, hash_):
|
||||
# NOTE: `self.cache_path` is already normalized so we can simply use
|
||||
# `os.sep` instead of `os.path.join`. This results in this helper
|
||||
# being ~5.5 times faster.
|
||||
return f"{self.cache_path}{os.sep}{hash_[0:2]}{os.sep}{hash_[2:]}"
|
||||
|
||||
def hashes_exist(
|
||||
self, hashes, jobs=None, name=None
|
||||
): # pylint: disable=unused-argument
|
||||
ret = []
|
||||
|
||||
for hash_ in Tqdm(
|
||||
hashes,
|
||||
unit="file",
|
||||
desc="Querying " + ("cache in " + name if name else "local cache"),
|
||||
):
|
||||
hash_info = HashInfo(self.fs.PARAM_CHECKSUM, hash_)
|
||||
try:
|
||||
self.check(hash_info)
|
||||
ret.append(hash_)
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
pass
|
||||
|
||||
return ret
|
||||
|
||||
def _list_paths(self, prefix=None, progress_callback=None):
|
||||
assert self.fs_path is not None
|
||||
if prefix:
|
||||
fs_path = self.fs.path.join(self.fs_path, prefix[:2])
|
||||
if not self.fs.exists(fs_path):
|
||||
return
|
||||
else:
|
||||
fs_path = self.fs_path
|
||||
|
||||
# NOTE: use utils.fs walk_files since fs.walk_files will not follow
|
||||
# symlinks
|
||||
if progress_callback:
|
||||
for path in walk_files(fs_path):
|
||||
progress_callback()
|
||||
yield path
|
||||
else:
|
||||
yield from walk_files(fs_path)
|
||||
|
||||
def _remove_unpacked_dir(self, hash_):
|
||||
hash_fs_path = self.hash_to_path(hash_)
|
||||
fs_path = self.fs.path.with_name(
|
||||
hash_fs_path,
|
||||
self.fs.path.name(hash_fs_path) + self.UNPACKED_DIR_SUFFIX,
|
||||
)
|
||||
self.fs.remove(fs_path)
|
||||
|
||||
def _unprotect_file(self, path):
|
||||
if self.fs.is_symlink(path) or self.fs.is_hardlink(path):
|
||||
logger.debug("Unprotecting '%s'", path)
|
||||
tmp = os.path.join(os.path.dirname(path), "." + uuid())
|
||||
|
||||
# The operations order is important here - if some application
|
||||
# would access the file during the process of copyfile then it
|
||||
# would get only the part of file. So, at first, the file should be
|
||||
# copied with the temporary name, and then original file should be
|
||||
# replaced by new.
|
||||
copyfile(path, tmp, name=f"Unprotecting '{relpath(path)}'")
|
||||
remove(path)
|
||||
os.rename(tmp, path)
|
||||
|
||||
else:
|
||||
logger.debug(
|
||||
"Skipping copying for '%s', since it is not "
|
||||
"a symlink or a hardlink.",
|
||||
path,
|
||||
)
|
||||
|
||||
os.chmod(path, self._file_mode)
|
||||
|
||||
def _unprotect_dir(self, path):
|
||||
for fname in self.fs.find(path):
|
||||
self._unprotect_file(fname)
|
||||
|
||||
def unprotect(self, fs_path):
|
||||
if not os.path.exists(fs_path):
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
raise DvcException(
|
||||
f"can't unprotect non-existing data '{fs_path}'"
|
||||
)
|
||||
|
||||
if os.path.isdir(fs_path):
|
||||
self._unprotect_dir(fs_path)
|
||||
else:
|
||||
self._unprotect_file(fs_path)
|
||||
|
||||
def protect(self, fs_path):
|
||||
try:
|
||||
os.chmod(fs_path, self.CACHE_MODE)
|
||||
except OSError:
|
||||
# NOTE: not being able to protect cache file is not fatal, it
|
||||
# might happen on funky filesystems (e.g. Samba, see #5255),
|
||||
# read-only filesystems or in a shared cache scenario.
|
||||
logger.trace("failed to protect '%s'", fs_path, exc_info=True)
|
||||
|
||||
def is_protected(self, fs_path):
|
||||
try:
|
||||
mode = os.stat(fs_path).st_mode
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
return stat.S_IMODE(mode) == self.CACHE_MODE
|
||||
|
||||
def set_exec(self, fs_path):
|
||||
mode = os.stat(fs_path).st_mode | stat.S_IEXEC
|
||||
try:
|
||||
os.chmod(fs_path, mode)
|
||||
except OSError:
|
||||
logger.trace(
|
||||
"failed to chmod '%s' '%s'",
|
||||
oct(mode),
|
||||
fs_path,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -1,88 +0,0 @@
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Dict
|
||||
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from ..reference import ReferenceHashFile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.base import FileSystem
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.types import AnyPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReferenceObjectDB(ObjectDB):
|
||||
"""Reference ODB.
|
||||
|
||||
File objects are stored as ReferenceHashFiles which reference paths outside
|
||||
of the staging ODB fs. Tree objects are stored natively.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._fs_cache: Dict[tuple, "FileSystem"] = {}
|
||||
self._obj_cache: Dict["HashInfo", "ReferenceHashFile"] = {}
|
||||
|
||||
def get(self, hash_info: "HashInfo"):
|
||||
if hash_info.isdir:
|
||||
return super().get(hash_info)
|
||||
try:
|
||||
return self._obj_cache[hash_info]
|
||||
except KeyError:
|
||||
pass
|
||||
fs_path = self.hash_to_path(hash_info.value)
|
||||
try:
|
||||
with self.fs.open(fs_path, "rb") as fobj:
|
||||
ref_file = ReferenceHashFile.from_bytes(
|
||||
fobj.read(), fs_cache=self._fs_cache
|
||||
)
|
||||
except OSError:
|
||||
raise FileNotFoundError
|
||||
try:
|
||||
ref_file.check(self, check_hash=False)
|
||||
except ObjectFormatError:
|
||||
self.fs.remove(fs_path)
|
||||
raise
|
||||
self._obj_cache[hash_info] = ref_file
|
||||
return ref_file
|
||||
|
||||
def _add_file(
|
||||
self,
|
||||
from_fs: "FileSystem",
|
||||
from_info: "AnyPath",
|
||||
to_info: "AnyPath",
|
||||
hash_info: "HashInfo",
|
||||
hardlink: bool = False,
|
||||
):
|
||||
self.makedirs(self.fs.path.parent(to_info))
|
||||
if hash_info.isdir:
|
||||
return super()._add_file(
|
||||
from_fs,
|
||||
from_info,
|
||||
to_info,
|
||||
hash_info,
|
||||
hardlink=hardlink,
|
||||
)
|
||||
ref_file = ReferenceHashFile(from_info, from_fs, hash_info)
|
||||
self._obj_cache[hash_info] = ref_file
|
||||
ref_fobj = io.BytesIO(ref_file.to_bytes())
|
||||
ref_fobj.seek(0)
|
||||
try:
|
||||
self.fs.upload(ref_fobj, to_info)
|
||||
except OSError as exc:
|
||||
if isinstance(exc, FileExistsError) or (
|
||||
os.name == "nt"
|
||||
and exc.__context__
|
||||
and isinstance(exc.__context__, FileExistsError)
|
||||
):
|
||||
logger.debug("'%s' file already exists, skipping", to_info)
|
||||
else:
|
||||
raise
|
||||
if from_fs.scheme != Schemes.LOCAL:
|
||||
self._fs_cache[ReferenceHashFile.config_tuple(from_fs)] = from_fs
|
||||
@@ -1,133 +0,0 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, List, Optional, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
ADD = "add"
|
||||
MODIFY = "modify"
|
||||
DELETE = "delete"
|
||||
UNCHANGED = "unchanged"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TreeEntry:
|
||||
in_cache: bool
|
||||
key: Tuple[str]
|
||||
oid: Optional["HashInfo"] = field(default=None)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.oid)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, TreeEntry):
|
||||
return False
|
||||
|
||||
if self.key != other.key:
|
||||
return False
|
||||
|
||||
return self.oid == other.oid
|
||||
|
||||
|
||||
@dataclass
|
||||
class Change:
|
||||
old: TreeEntry
|
||||
new: TreeEntry
|
||||
|
||||
@property
|
||||
def typ(self):
|
||||
if not self.old and not self.new:
|
||||
return UNCHANGED
|
||||
|
||||
if self.old and not self.new:
|
||||
return DELETE
|
||||
|
||||
if not self.old and self.new:
|
||||
return ADD
|
||||
|
||||
if self.old != self.new:
|
||||
return MODIFY
|
||||
|
||||
return UNCHANGED
|
||||
|
||||
def __bool__(self):
|
||||
return self.typ != UNCHANGED
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiffResult:
|
||||
added: List[Change] = field(default_factory=list, compare=True)
|
||||
modified: List[Change] = field(default_factory=list, compare=True)
|
||||
deleted: List[Change] = field(default_factory=list, compare=True)
|
||||
unchanged: List[Change] = field(default_factory=list, compare=True)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.added or self.modified or self.deleted)
|
||||
|
||||
|
||||
ROOT = ("",)
|
||||
|
||||
|
||||
def diff(
|
||||
old: Optional["HashFile"], new: Optional["HashFile"], cache
|
||||
) -> DiffResult:
|
||||
from .tree import Tree
|
||||
|
||||
if old is None and new is None:
|
||||
return DiffResult()
|
||||
|
||||
def _get_keys(obj):
|
||||
if not obj:
|
||||
return []
|
||||
return [ROOT] + (
|
||||
[key for key, _, _ in obj] if isinstance(obj, Tree) else []
|
||||
)
|
||||
|
||||
old_keys = set(_get_keys(old))
|
||||
new_keys = set(_get_keys(new))
|
||||
|
||||
def _get_oid(obj, key):
|
||||
if not obj or key == ROOT:
|
||||
return obj.hash_info if obj else None
|
||||
|
||||
entry_obj = obj.get(cache, key)
|
||||
return entry_obj.hash_info if entry_obj else None
|
||||
|
||||
def _in_cache(oid, cache):
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
|
||||
if not oid:
|
||||
return False
|
||||
|
||||
try:
|
||||
cache.check(oid)
|
||||
return True
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
return False
|
||||
|
||||
ret = DiffResult()
|
||||
for key in old_keys | new_keys:
|
||||
old_oid = _get_oid(old, key)
|
||||
new_oid = _get_oid(new, key)
|
||||
|
||||
change = Change(
|
||||
old=TreeEntry(_in_cache(old_oid, cache), key, old_oid),
|
||||
new=TreeEntry(_in_cache(new_oid, cache), key, new_oid),
|
||||
)
|
||||
|
||||
if change.typ == ADD:
|
||||
ret.added.append(change)
|
||||
elif change.typ == MODIFY:
|
||||
ret.modified.append(change)
|
||||
elif change.typ == DELETE:
|
||||
ret.deleted.append(change)
|
||||
else:
|
||||
assert change.typ == UNCHANGED
|
||||
if not change.new.in_cache and not (
|
||||
change.new.oid and change.new.oid.isdir
|
||||
):
|
||||
ret.modified.append(change)
|
||||
else:
|
||||
ret.unchanged.append(change)
|
||||
return ret
|
||||
@@ -1,40 +0,0 @@
|
||||
def gc(odb, used, jobs=None, cache_odb=None, shallow=True):
|
||||
from dvc.data.tree import Tree
|
||||
from dvc.objects.errors import ObjectDBPermissionError
|
||||
|
||||
if odb.read_only:
|
||||
raise ObjectDBPermissionError("Cannot gc read-only ODB")
|
||||
if not cache_odb:
|
||||
cache_odb = odb
|
||||
used_hashes = set()
|
||||
for hash_info in used:
|
||||
used_hashes.add(hash_info.value)
|
||||
if hash_info.isdir and not shallow:
|
||||
tree = Tree.load(cache_odb, hash_info)
|
||||
used_hashes.update(
|
||||
entry_obj.hash_info.value for _, entry_obj in tree
|
||||
)
|
||||
|
||||
def _is_dir_hash(_hash):
|
||||
from dvc.hash_info import HASH_DIR_SUFFIX
|
||||
|
||||
return _hash.endswith(HASH_DIR_SUFFIX)
|
||||
|
||||
removed = False
|
||||
# hashes must be sorted to ensure we always remove .dir files first
|
||||
for hash_ in sorted(
|
||||
odb.all(jobs, odb.fs_path),
|
||||
key=_is_dir_hash,
|
||||
reverse=True,
|
||||
):
|
||||
if hash_ in used_hashes:
|
||||
continue
|
||||
fs_path = odb.hash_to_path(hash_)
|
||||
if _is_dir_hash(hash_):
|
||||
# backward compatibility
|
||||
# pylint: disable=protected-access
|
||||
odb._remove_unpacked_dir(hash_)
|
||||
odb.fs.remove(fs_path)
|
||||
removed = True
|
||||
|
||||
return removed
|
||||
@@ -1,47 +0,0 @@
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
|
||||
@dataclass
|
||||
class Meta:
|
||||
PARAM_SIZE = "size"
|
||||
PARAM_NFILES = "nfiles"
|
||||
PARAM_ISEXEC = "isexec"
|
||||
|
||||
size: Optional[int] = field(default=None)
|
||||
nfiles: Optional[int] = field(default=None)
|
||||
isexec: Optional[bool] = field(default=False)
|
||||
|
||||
obj: Optional["HashFile"] = field(default=None)
|
||||
odb: Optional["ObjectDB"] = field(default=None)
|
||||
remote: Optional[str] = field(default=None)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
if not d:
|
||||
return cls()
|
||||
|
||||
size = d.pop(cls.PARAM_SIZE, None)
|
||||
nfiles = d.pop(cls.PARAM_NFILES, None)
|
||||
isexec = d.pop(cls.PARAM_ISEXEC, False)
|
||||
|
||||
return cls(size=size, nfiles=nfiles, isexec=isexec)
|
||||
|
||||
def to_dict(self):
|
||||
ret = OrderedDict()
|
||||
|
||||
if self.size is not None:
|
||||
ret[self.PARAM_SIZE] = self.size
|
||||
|
||||
if self.nfiles is not None:
|
||||
ret[self.PARAM_NFILES] = self.nfiles
|
||||
|
||||
if self.isexec:
|
||||
ret[self.PARAM_ISEXEC] = self.isexec
|
||||
|
||||
return ret
|
||||
@@ -1,120 +0,0 @@
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.base import FileSystem
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc.types import AnyPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReferenceHashFile(HashFile):
|
||||
PARAM_PATH = "path"
|
||||
PARAM_HASH = "hash"
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
PARAM_FS_CONFIG = "fs_config"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fs_path: "AnyPath",
|
||||
fs: "FileSystem",
|
||||
hash_info: "HashInfo",
|
||||
checksum: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(fs_path, fs, hash_info, **kwargs)
|
||||
self.checksum = checksum or fs.checksum(fs_path)
|
||||
|
||||
def __str__(self):
|
||||
return f"ref object {self.hash_info} -> {self.fs_path}"
|
||||
|
||||
def check(self, odb: "ObjectDB", check_hash: bool = True):
|
||||
assert self.fs
|
||||
if not self.fs.exists(self.fs_path):
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), self.fs_path
|
||||
)
|
||||
if self.checksum != self._get_checksum():
|
||||
raise ObjectFormatError(f"{self} is changed")
|
||||
if check_hash:
|
||||
self._check_hash(odb)
|
||||
|
||||
def _get_checksum(self) -> str:
|
||||
assert self.fs
|
||||
return self.fs.checksum(self.fs_path)
|
||||
|
||||
def to_bytes(self):
|
||||
from dvc.fs.repo import RepoFileSystem
|
||||
|
||||
# NOTE: dumping reference FS's this way is insecure, as the
|
||||
# fully parsed remote FS config will include credentials
|
||||
#
|
||||
# ReferenceHashFiles should currently only be serialized in
|
||||
# memory and not to disk
|
||||
fs_path = self.fs_path
|
||||
if isinstance(self.fs, RepoFileSystem):
|
||||
fs_path = self.fs.path.relpath(fs_path, self.fs.root_dir)
|
||||
|
||||
dict_ = {
|
||||
self.PARAM_PATH: fs_path,
|
||||
self.PARAM_HASH: self.hash_info,
|
||||
self.PARAM_CHECKSUM: self.checksum,
|
||||
self.PARAM_FS_CONFIG: self.config_tuple(self.fs),
|
||||
}
|
||||
try:
|
||||
return pickle.dumps(dict_)
|
||||
except pickle.PickleError as exc:
|
||||
raise ObjectFormatError(f"Could not pickle {self}") from exc
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes, fs_cache: Optional[dict] = None):
|
||||
from dvc.fs import get_fs_cls
|
||||
from dvc.fs.repo import RepoFileSystem
|
||||
|
||||
try:
|
||||
dict_ = pickle.loads(data)
|
||||
except pickle.PickleError as exc:
|
||||
raise ObjectFormatError("ReferenceHashFile is corrupted") from exc
|
||||
|
||||
try:
|
||||
fs_path = dict_[cls.PARAM_PATH]
|
||||
hash_info = dict_[cls.PARAM_HASH]
|
||||
except KeyError as exc:
|
||||
raise ObjectFormatError("ReferenceHashFile is corrupted") from exc
|
||||
|
||||
scheme, config_pairs = dict_.get(cls.PARAM_FS_CONFIG)
|
||||
fs = fs_cache.get((scheme, config_pairs)) if fs_cache else None
|
||||
if not fs:
|
||||
config = dict(config_pairs)
|
||||
if RepoFileSystem.PARAM_REPO_URL in config:
|
||||
fs = RepoFileSystem(**config)
|
||||
fs_path = fs.path.join(fs.root_dir, fs_path)
|
||||
else:
|
||||
fs_cls = get_fs_cls(config, scheme=scheme)
|
||||
fs = fs_cls(**config)
|
||||
return ReferenceHashFile(
|
||||
fs_path,
|
||||
fs,
|
||||
hash_info,
|
||||
checksum=dict_.get(cls.PARAM_CHECKSUM),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def config_tuple(fs: "FileSystem"):
|
||||
return (
|
||||
fs.scheme,
|
||||
tuple(
|
||||
(key, value)
|
||||
for key, value in sorted(
|
||||
fs.config.items(), key=lambda item: item[0]
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
# type: ignore
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
import colorama
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
this = sys.modules[__name__]
|
||||
|
||||
this.timeout_seconds = 10.0
|
||||
this.already_displayed = False
|
||||
this.message = (
|
||||
"You can cut execution time considerably by using a different "
|
||||
"`cache.type` configuration.\n"
|
||||
"See {blue}https://dvc.org/doc/commands-reference/config#cache{reset} "
|
||||
"for more information.\n"
|
||||
"To disable this message, run:\n"
|
||||
"`dvc config cache.slow_link_warning false`".format(
|
||||
blue=colorama.Fore.BLUE, reset=colorama.Fore.RESET
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def slow_link_guard(f):
|
||||
@wraps(f)
|
||||
def wrapper(self, odb, *args, **kwargs):
|
||||
if this.already_displayed:
|
||||
return f(self, odb, *args, **kwargs)
|
||||
|
||||
if not odb.slow_link_warning or odb.cache_types:
|
||||
return f(self, odb, *args, **kwargs)
|
||||
|
||||
start = time.time()
|
||||
result = f(self, odb, *args, **kwargs)
|
||||
delta = time.time() - start
|
||||
|
||||
if delta >= this.timeout_seconds:
|
||||
logger.warning(this.message)
|
||||
this.already_displayed = True
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
@@ -1,348 +0,0 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Tuple
|
||||
|
||||
from dvc.exceptions import DvcIgnoreInCollectedDirError
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.ignore import DvcIgnore
|
||||
from dvc.objects.file import HashFile
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.utils import file_md5, is_exec
|
||||
|
||||
from .db.reference import ReferenceObjectDB
|
||||
from .meta import Meta
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.base import FileSystem
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc.types import AnyPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_STAGING_MEMFS_PATH = "dvc-staging"
|
||||
|
||||
|
||||
def _upload_file(from_fs_path, fs, odb, upload_odb):
|
||||
from dvc.utils import tmp_fname
|
||||
from dvc.utils.stream import HashedStreamReader
|
||||
|
||||
fs_path = upload_odb.fs.path
|
||||
tmp_info = fs_path.join(upload_odb.fs_path, tmp_fname())
|
||||
with fs.open(from_fs_path, mode="rb", chunk_size=fs.CHUNK_SIZE) as stream:
|
||||
stream = HashedStreamReader(stream)
|
||||
size = fs.getsize(from_fs_path)
|
||||
upload_odb.fs.upload(
|
||||
stream, tmp_info, desc=fs_path.name(from_fs_path), total=size
|
||||
)
|
||||
|
||||
odb.add(tmp_info, upload_odb.fs, stream.hash_info)
|
||||
meta = Meta(size=size)
|
||||
return from_fs_path, meta, odb.get(stream.hash_info)
|
||||
|
||||
|
||||
def _adapt_info(info, scheme):
|
||||
if scheme == "s3" and "ETag" in info:
|
||||
info["etag"] = info["ETag"].strip('"')
|
||||
elif scheme == "gs" and "etag" in info:
|
||||
import base64
|
||||
|
||||
info["etag"] = base64.b64decode(info["etag"]).hex()
|
||||
elif scheme.startswith("http") and (
|
||||
"ETag" in info or "Content-MD5" in info
|
||||
):
|
||||
info["checksum"] = info.get("ETag") or info.get("Content-MD5")
|
||||
return info
|
||||
|
||||
|
||||
def _get_file_hash(fs_path, fs, name):
|
||||
info = _adapt_info(fs.info(fs_path), fs.scheme)
|
||||
|
||||
if name in info:
|
||||
assert not info[name].endswith(".dir")
|
||||
hash_value = info[name]
|
||||
elif hasattr(fs, name):
|
||||
func = getattr(fs, name)
|
||||
hash_value = func(fs_path)
|
||||
elif name == "md5":
|
||||
hash_value = file_md5(fs_path, fs)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
meta = Meta(size=info["size"], isexec=is_exec(info.get("mode", 0)))
|
||||
hash_info = HashInfo(name, hash_value)
|
||||
return meta, hash_info
|
||||
|
||||
|
||||
def get_file_hash(fs_path, fs, name, state=None):
|
||||
if state:
|
||||
meta, hash_info = state.get( # pylint: disable=assignment-from-none
|
||||
fs_path, fs
|
||||
)
|
||||
if hash_info:
|
||||
return meta, hash_info
|
||||
|
||||
meta, hash_info = _get_file_hash(fs_path, fs, name)
|
||||
|
||||
if state:
|
||||
assert ".dir" not in hash_info.value
|
||||
state.save(fs_path, fs, hash_info)
|
||||
|
||||
return meta, hash_info
|
||||
|
||||
|
||||
def _stage_file(fs_path, fs, name, odb=None, upload_odb=None, dry_run=False):
|
||||
state = odb.state if odb else None
|
||||
meta, hash_info = get_file_hash(fs_path, fs, name, state=state)
|
||||
if upload_odb and not dry_run:
|
||||
assert odb and name == "md5"
|
||||
return _upload_file(fs_path, fs, odb, upload_odb)
|
||||
|
||||
if dry_run:
|
||||
obj = HashFile(fs_path, fs, hash_info)
|
||||
else:
|
||||
odb.add(fs_path, fs, hash_info, hardlink=False)
|
||||
obj = odb.get(hash_info)
|
||||
|
||||
return fs_path, meta, obj
|
||||
|
||||
|
||||
def _build_objects(
|
||||
fs_path,
|
||||
fs,
|
||||
name,
|
||||
dvcignore=None,
|
||||
jobs=None,
|
||||
no_progress_bar=False,
|
||||
**kwargs,
|
||||
):
|
||||
if dvcignore:
|
||||
walk_iterator = dvcignore.find(fs, fs_path)
|
||||
else:
|
||||
walk_iterator = fs.find(fs_path)
|
||||
with Tqdm(
|
||||
unit="md5",
|
||||
desc="Computing file/dir hashes (only done once)",
|
||||
disable=no_progress_bar,
|
||||
) as pbar:
|
||||
worker = pbar.wrap_fn(
|
||||
partial(
|
||||
_stage_file,
|
||||
fs=fs,
|
||||
name=name,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=jobs if jobs is not None else fs.hash_jobs
|
||||
) as executor:
|
||||
yield from executor.map(worker, walk_iterator)
|
||||
|
||||
|
||||
def _iter_objects(fs_path, fs, name, **kwargs):
|
||||
yield from _build_objects(fs_path, fs, name, **kwargs)
|
||||
|
||||
|
||||
def _build_tree(fs_path, fs, name, **kwargs):
|
||||
from .tree import Tree
|
||||
|
||||
tree_meta = Meta(size=0, nfiles=0)
|
||||
tree = Tree(None, None, None)
|
||||
for file_fs_path, meta, obj in _iter_objects(fs_path, fs, name, **kwargs):
|
||||
if DvcIgnore.DVCIGNORE_FILE == fs.path.name(file_fs_path):
|
||||
raise DvcIgnoreInCollectedDirError(fs.path.parent(file_fs_path))
|
||||
|
||||
# NOTE: this is lossy transformation:
|
||||
# "hey\there" -> "hey/there"
|
||||
# "hey/there" -> "hey/there"
|
||||
# The latter is fine filename on Windows, which
|
||||
# will transform to dir/file on back transform.
|
||||
#
|
||||
# Yes, this is a BUG, as long as we permit "/" in
|
||||
# filenames on Windows and "\" on Unix
|
||||
|
||||
key = fs.path.relparts(file_fs_path, fs_path)
|
||||
assert key
|
||||
tree.add(key, meta, obj.hash_info)
|
||||
|
||||
tree_meta.size += meta.size or 0
|
||||
tree_meta.nfiles += 1
|
||||
|
||||
return tree_meta, tree
|
||||
|
||||
|
||||
def _stage_tree(fs_path, fs, fs_info, name, odb=None, **kwargs):
|
||||
from .tree import Tree
|
||||
|
||||
value = fs_info.get(name)
|
||||
if odb and value:
|
||||
hash_info = HashInfo(name, value)
|
||||
try:
|
||||
tree = Tree.load(odb, hash_info)
|
||||
return Meta(nfiles=len(tree)), tree
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
meta, tree = _build_tree(fs_path, fs, name, odb=odb, **kwargs)
|
||||
state = odb.state if odb and odb.state else None
|
||||
hash_info = None
|
||||
if state:
|
||||
_, hash_info = state.get( # pylint: disable=assignment-from-none
|
||||
fs_path, fs
|
||||
)
|
||||
tree.digest(hash_info=hash_info)
|
||||
odb.add(tree.fs_path, tree.fs, tree.hash_info, hardlink=False)
|
||||
raw = odb.get(tree.hash_info)
|
||||
# cleanup unneeded memfs tmpfile and return tree based on the
|
||||
# ODB fs/path
|
||||
if odb.fs != tree.fs:
|
||||
tree.fs.remove(tree.fs_path)
|
||||
tree.fs = raw.fs
|
||||
tree.fs_path = raw.fs_path
|
||||
return meta, tree
|
||||
|
||||
|
||||
_url_cache: Dict[str, str] = {}
|
||||
|
||||
|
||||
def _make_staging_url(
|
||||
fs: "FileSystem", odb: "ObjectDB", fs_path: Optional[str]
|
||||
):
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
url = f"{Schemes.MEMORY}://{_STAGING_MEMFS_PATH}"
|
||||
|
||||
if fs_path is not None:
|
||||
if odb.fs.scheme == Schemes.LOCAL:
|
||||
fs_path = os.path.abspath(fs_path)
|
||||
|
||||
if fs_path not in _url_cache:
|
||||
_url_cache[fs_path] = hashlib.sha256(
|
||||
fs_path.encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
url = fs.path.join(url, _url_cache[fs_path])
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def _get_staging(odb: "ObjectDB") -> "ObjectDB":
|
||||
"""Return an ODB that can be used for staging objects.
|
||||
|
||||
Staging will be a reference ODB stored in the the global memfs.
|
||||
"""
|
||||
|
||||
from dvc.fs.memory import MemoryFileSystem
|
||||
|
||||
fs = MemoryFileSystem()
|
||||
fs_path = _make_staging_url(fs, odb, odb.fs_path)
|
||||
state = odb.state
|
||||
return ReferenceObjectDB(fs, fs_path, state=state)
|
||||
|
||||
|
||||
def _load_from_state(odb, staging, fs_path, fs, name):
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
|
||||
from . import check, load
|
||||
from .tree import Tree
|
||||
|
||||
state = odb.state
|
||||
meta, hash_info = state.get(fs_path, fs)
|
||||
if hash_info:
|
||||
for odb_ in (odb, staging):
|
||||
if odb_.exists(hash_info):
|
||||
try:
|
||||
obj = load(odb_, hash_info)
|
||||
check(odb_, obj, check_hash=False)
|
||||
if isinstance(obj, Tree):
|
||||
meta.nfiles = len(obj)
|
||||
assert obj.hash_info.name == name
|
||||
return odb_, meta, obj
|
||||
except (ObjectFormatError, FileNotFoundError):
|
||||
pass
|
||||
raise FileNotFoundError
|
||||
|
||||
|
||||
def _stage_external_tree_info(odb, tree, name):
|
||||
# NOTE: used only for external outputs. Initial reasoning was to be
|
||||
# able to validate .dir files right in the workspace (e.g. check s3
|
||||
# etag), but could be dropped for manual validation with regular md5,
|
||||
# that would be universal for all clouds.
|
||||
assert odb and name != "md5"
|
||||
|
||||
odb.add(tree.fs_path, tree.fs, tree.hash_info)
|
||||
raw = odb.get(tree.hash_info)
|
||||
_, hash_info = get_file_hash(raw.fs_path, raw.fs, name, state=odb.state)
|
||||
tree.fs_path = raw.fs_path
|
||||
tree.fs = raw.fs
|
||||
tree.hash_info.name = hash_info.name
|
||||
tree.hash_info.value = hash_info.value
|
||||
if not tree.hash_info.value.endswith(".dir"):
|
||||
tree.hash_info.value += ".dir"
|
||||
return tree
|
||||
|
||||
|
||||
def stage(
|
||||
odb: "ObjectDB",
|
||||
fs_path: "AnyPath",
|
||||
fs: "FileSystem",
|
||||
name: str,
|
||||
upload: bool = False,
|
||||
dry_run: bool = False,
|
||||
**kwargs,
|
||||
) -> Tuple["ObjectDB", "Meta", "HashFile"]:
|
||||
"""Stage (prepare) objects from the given path for addition to an ODB.
|
||||
|
||||
Returns at tuple of (staging_odb, object) where addition to the ODB can
|
||||
be completed by transferring the object from staging to the dest ODB.
|
||||
|
||||
If dry_run is True, object hashes will be computed and returned, but file
|
||||
objects themselves will not be added to the staging ODB (i.e. the resulting
|
||||
file objects cannot transferred from staging to another ODB).
|
||||
|
||||
If upload is True, files will be uploaded to a temporary path on the dest
|
||||
ODB filesystem, and staged objects will reference the uploaded path rather
|
||||
than the original source path.
|
||||
"""
|
||||
assert fs_path
|
||||
# assert scheme(fs_path) == fs.scheme
|
||||
|
||||
details = fs.info(fs_path)
|
||||
staging = _get_staging(odb)
|
||||
if odb:
|
||||
try:
|
||||
return _load_from_state(odb, staging, fs_path, fs, name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
if details["type"] == "directory":
|
||||
meta, obj = _stage_tree(
|
||||
fs_path,
|
||||
fs,
|
||||
details,
|
||||
name,
|
||||
odb=staging,
|
||||
upload_odb=odb if upload else None,
|
||||
dry_run=dry_run,
|
||||
**kwargs,
|
||||
)
|
||||
logger.debug("staged tree '%s'", obj)
|
||||
if name != "md5":
|
||||
obj = _stage_external_tree_info(odb, obj, name)
|
||||
else:
|
||||
_, meta, obj = _stage_file(
|
||||
fs_path,
|
||||
fs,
|
||||
name,
|
||||
odb=staging,
|
||||
upload_odb=odb if upload else None,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
if odb and odb.state and obj.hash_info:
|
||||
odb.state.save(fs_path, fs, obj.hash_info)
|
||||
|
||||
return staging, meta, obj
|
||||
@@ -1,186 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Dict, Iterable, NamedTuple, Optional, Set
|
||||
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .tree import Tree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
from .db.index import ObjectDBIndexBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StatusResult(NamedTuple):
|
||||
exists: Set["HashInfo"]
|
||||
missing: Set["HashInfo"]
|
||||
|
||||
|
||||
class CompareStatusResult(NamedTuple):
|
||||
ok: Set["HashInfo"]
|
||||
missing: Set["HashInfo"]
|
||||
new: Set["HashInfo"]
|
||||
deleted: Set["HashInfo"]
|
||||
|
||||
|
||||
def _indexed_dir_hashes(odb, index, dir_objs, name, cache_odb):
|
||||
# Validate our index by verifying all indexed .dir hashes
|
||||
# still exist on the remote
|
||||
dir_hashes = set(dir_objs.keys())
|
||||
indexed_dirs = set(index.dir_hashes())
|
||||
indexed_dir_exists = set()
|
||||
if indexed_dirs:
|
||||
indexed_dir_exists.update(odb.list_hashes_exists(indexed_dirs))
|
||||
missing_dirs = indexed_dirs.difference(indexed_dir_exists)
|
||||
if missing_dirs:
|
||||
logger.debug(
|
||||
"Remote cache missing indexed .dir hashes '%s', "
|
||||
"clearing remote index",
|
||||
", ".join(missing_dirs),
|
||||
)
|
||||
index.clear()
|
||||
|
||||
# Check if non-indexed (new) dir hashes exist on remote
|
||||
dir_exists = dir_hashes.intersection(indexed_dir_exists)
|
||||
dir_exists.update(odb.list_hashes_exists(dir_hashes - dir_exists))
|
||||
|
||||
# If .dir hash exists in the ODB, assume directory contents
|
||||
# also exists
|
||||
for dir_hash in dir_exists:
|
||||
tree = dir_objs.get(dir_hash)
|
||||
if not tree:
|
||||
try:
|
||||
tree = Tree.load(cache_odb, HashInfo(name, dir_hash))
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
file_hashes = [oid.value for _, _, oid in tree]
|
||||
if dir_hash not in index:
|
||||
logger.debug(
|
||||
"Indexing new .dir '%s' with '%s' nested files",
|
||||
dir_hash,
|
||||
len(file_hashes),
|
||||
)
|
||||
index.update([dir_hash], file_hashes)
|
||||
yield from file_hashes
|
||||
yield tree.hash_info.value
|
||||
|
||||
|
||||
def status(
|
||||
odb: "ObjectDB",
|
||||
obj_ids: Iterable["HashInfo"],
|
||||
name: Optional[str] = None,
|
||||
index: Optional["ObjectDBIndexBase"] = None,
|
||||
cache_odb: Optional["ObjectDB"] = None,
|
||||
shallow: bool = True,
|
||||
**kwargs,
|
||||
) -> "StatusResult":
|
||||
"""Return status of whether or not the specified objects exist odb.
|
||||
|
||||
If cache_odb is set, trees will be loaded from cache_odb instead of odb
|
||||
when needed.
|
||||
|
||||
Status is returned as a tuple of:
|
||||
exists: objs that exist in odb
|
||||
missing: objs that do not exist in ODB
|
||||
"""
|
||||
logger.debug("Preparing to collect status from '%s'", odb.fs_path)
|
||||
if not name:
|
||||
name = odb.fs.PARAM_CHECKSUM
|
||||
|
||||
if cache_odb is None:
|
||||
cache_odb = odb
|
||||
|
||||
hash_infos: Dict[str, "HashInfo"] = {}
|
||||
dir_objs: Dict[str, Optional["HashFile"]] = {}
|
||||
for hash_info in obj_ids:
|
||||
assert hash_info.value
|
||||
if hash_info.isdir:
|
||||
if shallow:
|
||||
tree = None
|
||||
else:
|
||||
tree = Tree.load(cache_odb, hash_info)
|
||||
for _, _, oid in tree:
|
||||
assert oid and oid.value
|
||||
hash_infos[oid.value] = oid
|
||||
if index:
|
||||
dir_objs[hash_info.value] = tree
|
||||
hash_infos[hash_info.value] = hash_info
|
||||
|
||||
if odb.fs.scheme == Schemes.MEMORY:
|
||||
# assume memfs staged objects already exist
|
||||
return StatusResult(set(hash_infos.values()), set())
|
||||
|
||||
hashes: Set[str] = set(hash_infos.keys())
|
||||
exists: Set[str] = set()
|
||||
|
||||
logger.debug("Collecting status from '%s'", odb.fs_path)
|
||||
if index and hashes:
|
||||
if dir_objs:
|
||||
exists = hashes.intersection(
|
||||
_indexed_dir_hashes(odb, index, dir_objs, name, cache_odb)
|
||||
)
|
||||
hashes.difference_update(exists)
|
||||
if hashes:
|
||||
exists.update(index.intersection(hashes))
|
||||
hashes.difference_update(exists)
|
||||
|
||||
if hashes:
|
||||
exists.update(odb.hashes_exist(hashes, name=odb.fs_path, **kwargs))
|
||||
return StatusResult(
|
||||
{hash_infos[hash_] for hash_ in exists},
|
||||
{hash_infos[hash_] for hash_ in (hashes - exists)},
|
||||
)
|
||||
|
||||
|
||||
def compare_status(
|
||||
src: "ObjectDB",
|
||||
dest: "ObjectDB",
|
||||
obj_ids: Iterable["HashInfo"],
|
||||
log_missing: bool = True,
|
||||
check_deleted: bool = True,
|
||||
src_index: Optional["ObjectDBIndexBase"] = None,
|
||||
dest_index: Optional["ObjectDBIndexBase"] = None,
|
||||
**kwargs,
|
||||
) -> "CompareStatusResult":
|
||||
"""Compare status for the specified objects between two ODBs.
|
||||
|
||||
Status is returned as a tuple of:
|
||||
ok: hashes that exist in both src and dest
|
||||
missing: hashes that do not exist in neither src nor dest
|
||||
new: hashes that only exist in src
|
||||
deleted: hashes that only exist in dest
|
||||
"""
|
||||
if "cache_odb" not in kwargs:
|
||||
kwargs["cache_odb"] = src
|
||||
dest_exists, dest_missing = status(
|
||||
dest, obj_ids, index=dest_index, **kwargs
|
||||
)
|
||||
# for transfer operations we can skip src status check when all objects
|
||||
# already exist in dest
|
||||
if dest_missing or check_deleted:
|
||||
src_exists, src_missing = status(
|
||||
src, obj_ids, index=src_index, **kwargs
|
||||
)
|
||||
else:
|
||||
src_exists = dest_exists
|
||||
src_missing = set()
|
||||
result = CompareStatusResult(
|
||||
src_exists & dest_exists,
|
||||
src_missing & dest_missing,
|
||||
src_exists - dest_exists,
|
||||
dest_exists - src_exists,
|
||||
)
|
||||
if log_missing and result.missing:
|
||||
missing_desc = "\n".join(
|
||||
f"name: {hash_info.obj_name}, {hash_info}"
|
||||
for hash_info in result.missing
|
||||
)
|
||||
logger.warning(
|
||||
"Some of the cache files do not exist neither locally "
|
||||
f"nor on remote. Missing cache files:\n{missing_desc}"
|
||||
)
|
||||
return result
|
||||
@@ -1,177 +0,0 @@
|
||||
import errno
|
||||
import logging
|
||||
from functools import partial, wraps
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional
|
||||
|
||||
from funcy import split
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.utils.threadpool import ThreadPoolExecutor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB
|
||||
|
||||
from .db.index import ObjectDBIndexBase
|
||||
from .tree import Tree
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _log_exceptions(func):
|
||||
@wraps(func)
|
||||
def wrapper(fs_path, *args, **kwargs):
|
||||
try:
|
||||
func(fs_path, *args, **kwargs)
|
||||
return 0
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# NOTE: this means we ran out of file descriptors and there is no
|
||||
# reason to try to proceed, as we will hit this error anyways.
|
||||
# pylint: disable=no-member
|
||||
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
|
||||
raise
|
||||
|
||||
logger.exception("failed to transfer '%s'", fs_path)
|
||||
return 1
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def find_tree_by_obj_id(
|
||||
odbs: Iterable[Optional["ObjectDB"]], obj_id: "HashInfo"
|
||||
) -> Optional["Tree"]:
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
|
||||
from .tree import Tree
|
||||
|
||||
for odb in odbs:
|
||||
if odb is not None:
|
||||
try:
|
||||
return Tree.load(odb, obj_id)
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _do_transfer(
|
||||
src: "ObjectDB",
|
||||
dest: "ObjectDB",
|
||||
obj_ids: Iterable["HashInfo"],
|
||||
missing_ids: Iterable["HashInfo"],
|
||||
processor: Callable,
|
||||
src_index: Optional["ObjectDBIndexBase"] = None,
|
||||
dest_index: Optional["ObjectDBIndexBase"] = None,
|
||||
cache_odb: Optional["ObjectDB"] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
from dvc.exceptions import FileTransferError
|
||||
|
||||
dir_ids, file_ids = split(lambda hash_info: hash_info.isdir, obj_ids)
|
||||
total_fails = 0
|
||||
succeeded_dir_objs = []
|
||||
all_file_ids = set(file_ids)
|
||||
|
||||
for dir_hash in dir_ids:
|
||||
dir_obj = find_tree_by_obj_id([cache_odb, src], dir_hash)
|
||||
assert dir_obj
|
||||
|
||||
entry_ids = {oid for _, _, oid in dir_obj}
|
||||
bound_file_ids = all_file_ids & entry_ids
|
||||
all_file_ids -= entry_ids
|
||||
|
||||
dir_fails = sum(processor(bound_file_ids))
|
||||
if dir_fails:
|
||||
logger.debug(
|
||||
"failed to upload full contents of '%s', "
|
||||
"aborting .dir file upload",
|
||||
dir_obj.name,
|
||||
)
|
||||
logger.error(
|
||||
"failed to upload '%s' to '%s'",
|
||||
src.get(dir_obj.hash_info).fs_path,
|
||||
dest.get(dir_obj.hash_info).fs_path,
|
||||
)
|
||||
total_fails += dir_fails + 1
|
||||
elif entry_ids.intersection(missing_ids):
|
||||
# if for some reason a file contained in this dir is
|
||||
# missing both locally and in the remote, we want to
|
||||
# push whatever file content we have, but should not
|
||||
# push .dir file
|
||||
logger.debug(
|
||||
"directory '%s' contains missing files,"
|
||||
"skipping .dir file upload",
|
||||
dir_obj.name,
|
||||
)
|
||||
else:
|
||||
is_dir_failed = sum(processor([dir_obj.hash_info]))
|
||||
total_fails += is_dir_failed
|
||||
if not is_dir_failed:
|
||||
succeeded_dir_objs.append(dir_obj)
|
||||
|
||||
# insert the rest
|
||||
total_fails += sum(processor(all_file_ids))
|
||||
if total_fails:
|
||||
if src_index:
|
||||
src_index.clear()
|
||||
raise FileTransferError(total_fails)
|
||||
|
||||
# index successfully pushed dirs
|
||||
if dest_index:
|
||||
for dir_obj in succeeded_dir_objs:
|
||||
file_hashes = {oid.value for _, _, oid in dir_obj}
|
||||
logger.debug(
|
||||
"Indexing pushed dir '%s' with '%s' nested files",
|
||||
dir_obj.hash_info,
|
||||
len(file_hashes),
|
||||
)
|
||||
assert dir_obj.hash_info and dir_obj.hash_info.value
|
||||
dest_index.update([dir_obj.hash_info.value], file_hashes)
|
||||
|
||||
|
||||
def transfer(
|
||||
src: "ObjectDB",
|
||||
dest: "ObjectDB",
|
||||
obj_ids: Iterable["HashInfo"],
|
||||
jobs: Optional[int] = None,
|
||||
verify: bool = False,
|
||||
hardlink: bool = False,
|
||||
**kwargs,
|
||||
) -> int:
|
||||
"""Transfer (copy) the specified objects from one ODB to another.
|
||||
|
||||
Returns the number of successfully transferred objects
|
||||
"""
|
||||
from .status import compare_status
|
||||
|
||||
logger.debug(
|
||||
"Preparing to transfer data from '%s' to '%s'",
|
||||
src.fs_path,
|
||||
dest.fs_path,
|
||||
)
|
||||
if src == dest:
|
||||
return 0
|
||||
|
||||
status = compare_status(src, dest, obj_ids, check_deleted=False, **kwargs)
|
||||
if not status.new:
|
||||
return 0
|
||||
|
||||
def func(hash_info: "HashInfo") -> None:
|
||||
obj = src.get(hash_info)
|
||||
return dest.add(
|
||||
obj.fs_path,
|
||||
obj.fs,
|
||||
obj.hash_info,
|
||||
verify=verify,
|
||||
hardlink=hardlink,
|
||||
)
|
||||
|
||||
total = len(status.new)
|
||||
jobs = jobs or dest.fs.jobs
|
||||
with Tqdm(total=total, unit="file", desc="Transferring") as pbar:
|
||||
with ThreadPoolExecutor(max_workers=jobs) as executor:
|
||||
wrapped_func = pbar.wrap_fn(_log_exceptions(func))
|
||||
processor = partial(executor.imap_unordered, wrapped_func)
|
||||
_do_transfer(
|
||||
src, dest, status.new, status.missing, processor, **kwargs
|
||||
)
|
||||
return total
|
||||
@@ -1,269 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import posixpath
|
||||
from typing import TYPE_CHECKING, Dict, Iterable, Optional, Tuple
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
from .stage import get_file_hash
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB
|
||||
|
||||
from .meta import Meta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _try_load(
|
||||
odbs: Iterable["ObjectDB"],
|
||||
hash_info: "HashInfo",
|
||||
) -> Optional["HashFile"]:
|
||||
for odb in odbs:
|
||||
if not odb:
|
||||
continue
|
||||
|
||||
try:
|
||||
return Tree.load(odb, hash_info)
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class Tree(HashFile):
|
||||
PARAM_RELPATH = "relpath"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._dict: Dict[Tuple[str], Tuple["Meta", "HashInfo"]] = {}
|
||||
|
||||
@cached_property
|
||||
def _trie(self):
|
||||
from pygtrie import Trie
|
||||
|
||||
return Trie(self._dict)
|
||||
|
||||
def add(self, key: Tuple[str], meta: "Meta", oid: "HashInfo"):
|
||||
self.__dict__.pop("trie", None)
|
||||
self._dict[key] = (meta, oid)
|
||||
|
||||
def digest(self, hash_info: Optional["HashInfo"] = None):
|
||||
from dvc.fs.memory import MemoryFileSystem
|
||||
from dvc.utils import tmp_fname
|
||||
|
||||
memfs = MemoryFileSystem()
|
||||
fs_path = "memory://{}".format(tmp_fname(""))
|
||||
with memfs.open(fs_path, "wb") as fobj:
|
||||
fobj.write(self.as_bytes())
|
||||
self.fs = memfs
|
||||
self.fs_path = fs_path
|
||||
if hash_info:
|
||||
self.hash_info = hash_info
|
||||
else:
|
||||
_, self.hash_info = get_file_hash(fs_path, memfs, "md5")
|
||||
assert self.hash_info.value
|
||||
self.hash_info.value += ".dir"
|
||||
|
||||
def _load(self, key, meta, hash_info):
|
||||
if hash_info and hash_info.isdir and not meta.obj:
|
||||
meta.obj = _try_load([meta.odb, meta.remote], hash_info)
|
||||
if meta.obj:
|
||||
for ikey, value in meta.obj.iteritems():
|
||||
self._trie[key + ikey] = value
|
||||
self._dict[key + ikey] = value
|
||||
|
||||
def iteritems(self, prefix=None):
|
||||
kwargs = {}
|
||||
if prefix:
|
||||
kwargs = {"prefix": prefix}
|
||||
item = self._trie.longest_prefix(prefix)
|
||||
if item:
|
||||
key, (meta, hash_info) = item
|
||||
self._load(key, meta, hash_info)
|
||||
|
||||
for key, (meta, hash_info) in self._trie.iteritems(**kwargs):
|
||||
self._load(key, meta, hash_info)
|
||||
yield key, (meta, hash_info)
|
||||
|
||||
def shortest_prefix(self, *args, **kwargs):
|
||||
return self._trie.shortest_prefix(*args, **kwargs)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._dict)
|
||||
|
||||
def __iter__(self):
|
||||
yield from (
|
||||
(key, value[0], value[1]) for key, value in self._dict.items()
|
||||
)
|
||||
|
||||
def as_dict(self):
|
||||
return self._dict.copy()
|
||||
|
||||
def as_list(self):
|
||||
from operator import itemgetter
|
||||
|
||||
# Sorting the list by path to ensure reproducibility
|
||||
return sorted(
|
||||
(
|
||||
{
|
||||
# NOTE: not using hash_info.to_dict() because we don't want
|
||||
# size/nfiles fields at this point.
|
||||
oid.name: oid.value,
|
||||
self.PARAM_RELPATH: posixpath.sep.join(parts),
|
||||
}
|
||||
for parts, _, oid in self # noqa: B301
|
||||
),
|
||||
key=itemgetter(self.PARAM_RELPATH),
|
||||
)
|
||||
|
||||
def as_bytes(self):
|
||||
return json.dumps(self.as_list(), sort_keys=True).encode("utf-8")
|
||||
|
||||
@classmethod
|
||||
def from_list(cls, lst):
|
||||
from dvc.hash_info import HashInfo
|
||||
|
||||
tree = cls(None, None, None)
|
||||
for _entry in lst:
|
||||
entry = _entry.copy()
|
||||
relpath = entry.pop(cls.PARAM_RELPATH)
|
||||
parts = tuple(relpath.split(posixpath.sep))
|
||||
hash_info = HashInfo.from_dict(entry)
|
||||
tree.add(parts, None, hash_info)
|
||||
return tree
|
||||
|
||||
@classmethod
|
||||
def load(cls, odb, hash_info):
|
||||
obj = odb.get(hash_info)
|
||||
|
||||
try:
|
||||
with obj.fs.open(obj.fs_path, "r") as fobj:
|
||||
raw = json.load(fobj)
|
||||
except ValueError as exc:
|
||||
raise ObjectFormatError(f"{obj} is corrupted") from exc
|
||||
|
||||
if not isinstance(raw, list):
|
||||
logger.error(
|
||||
"dir cache file format error '%s' [skipping the file]",
|
||||
obj.fs_path,
|
||||
)
|
||||
raise ObjectFormatError(f"{obj} is corrupted")
|
||||
|
||||
tree = cls.from_list(raw)
|
||||
tree.fs_path = obj.fs_path
|
||||
tree.fs = obj.fs
|
||||
tree.hash_info = hash_info
|
||||
|
||||
return tree
|
||||
|
||||
def filter(self, prefix: Tuple[str]) -> Optional["Tree"]:
|
||||
"""Return a filtered copy of this tree that only contains entries
|
||||
inside prefix.
|
||||
|
||||
The returned tree will contain the original tree's hash_info and
|
||||
fs_path.
|
||||
|
||||
Returns an empty tree if no object exists at the specified prefix.
|
||||
"""
|
||||
tree = Tree(self.fs_path, self.fs, self.hash_info)
|
||||
try:
|
||||
for key, (meta, oid) in self._trie.items(prefix):
|
||||
tree.add(key, meta, oid)
|
||||
except KeyError:
|
||||
pass
|
||||
return tree
|
||||
|
||||
def get(self, odb, prefix: Tuple[str]) -> Optional[HashFile]:
|
||||
"""Return object at the specified prefix in this tree.
|
||||
|
||||
Returns None if no object exists at the specified prefix.
|
||||
"""
|
||||
_, oid = self._dict.get(prefix) or (None, None)
|
||||
if oid:
|
||||
return odb.get(oid)
|
||||
|
||||
tree = Tree(None, None, None)
|
||||
depth = len(prefix)
|
||||
try:
|
||||
for key, (meta, entry_oid) in self._trie.items(prefix):
|
||||
tree.add(key[depth:], meta, entry_oid)
|
||||
except KeyError:
|
||||
return None
|
||||
tree.digest()
|
||||
return tree
|
||||
|
||||
|
||||
def du(odb, tree):
|
||||
try:
|
||||
return sum(
|
||||
odb.fs.getsize(odb.hash_to_path(oid.value)) for _, _, oid in tree
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _diff(ancestor, other, allow_removed=False):
|
||||
from dictdiffer import diff
|
||||
|
||||
from dvc.exceptions import MergeError
|
||||
|
||||
allowed = ["add"]
|
||||
if allow_removed:
|
||||
allowed.append("remove")
|
||||
|
||||
result = list(diff(ancestor, other))
|
||||
for typ, _, _ in result:
|
||||
if typ not in allowed:
|
||||
raise MergeError(
|
||||
"unable to auto-merge directories with diff that contains "
|
||||
f"'{typ}'ed files"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _merge(ancestor, our, their):
|
||||
import copy
|
||||
|
||||
from dictdiffer import patch
|
||||
|
||||
our_diff = _diff(ancestor, our)
|
||||
if not our_diff:
|
||||
return copy.deepcopy(their)
|
||||
|
||||
their_diff = _diff(ancestor, their)
|
||||
if not their_diff:
|
||||
return copy.deepcopy(our)
|
||||
|
||||
# make sure there are no conflicting files
|
||||
_diff(our, their, allow_removed=True)
|
||||
|
||||
return patch(our_diff + their_diff, ancestor)
|
||||
|
||||
|
||||
def merge(odb, ancestor_info, our_info, their_info):
|
||||
from . import load
|
||||
|
||||
assert our_info
|
||||
assert their_info
|
||||
|
||||
if ancestor_info:
|
||||
ancestor = load(odb, ancestor_info)
|
||||
else:
|
||||
ancestor = Tree(None, None, None)
|
||||
|
||||
our = load(odb, our_info)
|
||||
their = load(odb, their_info)
|
||||
|
||||
merged_dict = _merge(ancestor.as_dict(), our.as_dict(), their.as_dict())
|
||||
|
||||
merged = Tree(None, None, None)
|
||||
for key, (meta, oid) in merged_dict.items():
|
||||
merged.add(key, meta, oid)
|
||||
merged.digest()
|
||||
|
||||
return merged
|
||||
+38
-15
@@ -3,11 +3,12 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Iterable, Optional
|
||||
|
||||
from dvc.data.db import get_index
|
||||
from dvc_data.db import get_index
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc_data.status import CompareStatusResult
|
||||
from dvc_objects.db import ObjectDB
|
||||
from dvc_objects.hash_info import HashInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,13 +56,39 @@ class DataCloud:
|
||||
raise NoRemoteError(error_msg)
|
||||
|
||||
def _init_odb(self, name):
|
||||
from dvc.data.db import get_odb
|
||||
from dvc.fs import get_cloud_fs
|
||||
from dvc_data.db import get_odb
|
||||
|
||||
cls, config, fs_path = get_cloud_fs(self.repo, name=name)
|
||||
config["tmp_dir"] = self.repo.index_db_dir
|
||||
return get_odb(cls(**config), fs_path, **config)
|
||||
|
||||
def _log_missing(self, status: "CompareStatusResult"):
|
||||
if status.missing:
|
||||
missing_desc = "\n".join(
|
||||
f"name: {hash_info.obj_name}, {hash_info}"
|
||||
for hash_info in status.missing
|
||||
)
|
||||
logger.warning(
|
||||
"Some of the cache files do not exist neither locally "
|
||||
f"nor on remote. Missing cache files:\n{missing_desc}"
|
||||
)
|
||||
|
||||
def transfer(
|
||||
self,
|
||||
src_odb: "ObjectDB",
|
||||
dest_odb: "ObjectDB",
|
||||
objs: Iterable["HashInfo"],
|
||||
**kwargs,
|
||||
):
|
||||
from dvc.exceptions import FileTransferError
|
||||
from dvc_data.transfer import TransferError, transfer
|
||||
|
||||
try:
|
||||
return transfer(src_odb, dest_odb, objs, **kwargs)
|
||||
except TransferError as exc:
|
||||
raise FileTransferError(exc.fails) from exc
|
||||
|
||||
def push(
|
||||
self,
|
||||
objs: Iterable["HashInfo"],
|
||||
@@ -78,17 +105,15 @@ class DataCloud:
|
||||
By default remote from core.remote config option is used.
|
||||
odb: optional ODB to push to. Overrides remote.
|
||||
"""
|
||||
from dvc.data.transfer import transfer
|
||||
|
||||
if not odb:
|
||||
odb = self.get_remote_odb(remote, "push")
|
||||
return transfer(
|
||||
odb = odb or self.get_remote_odb(remote, "push")
|
||||
return self.transfer(
|
||||
self.repo.odb.local,
|
||||
odb,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
dest_index=get_index(odb),
|
||||
cache_odb=self.repo.odb.local,
|
||||
validate_status=self._log_missing,
|
||||
)
|
||||
|
||||
def pull(
|
||||
@@ -107,11 +132,8 @@ class DataCloud:
|
||||
By default remote from core.remote config option is used.
|
||||
odb: optional ODB to pull from. Overrides remote.
|
||||
"""
|
||||
from dvc.data.transfer import transfer
|
||||
|
||||
if not odb:
|
||||
odb = self.get_remote_odb(remote, "pull")
|
||||
return transfer(
|
||||
odb = odb or self.get_remote_odb(remote, "pull")
|
||||
return self.transfer(
|
||||
odb,
|
||||
self.repo.odb.local,
|
||||
objs,
|
||||
@@ -119,6 +141,7 @@ class DataCloud:
|
||||
src_index=get_index(odb),
|
||||
cache_odb=self.repo.odb.local,
|
||||
verify=odb.verify,
|
||||
validate_status=self._log_missing,
|
||||
)
|
||||
|
||||
def status(
|
||||
@@ -141,7 +164,7 @@ class DataCloud:
|
||||
log_missing: log warning messages if file doesn't exist
|
||||
neither in cache, neither in cloud.
|
||||
"""
|
||||
from dvc.data.status import compare_status
|
||||
from dvc_data.status import compare_status
|
||||
|
||||
if not odb:
|
||||
odb = self.get_remote_odb(remote, "status")
|
||||
|
||||
@@ -47,16 +47,29 @@ def loads_from(stage, s_list, erepo=None):
|
||||
def _merge_params(s_list):
|
||||
d = defaultdict(list)
|
||||
default_file = ParamsDependency.DEFAULT_PARAMS_FILE
|
||||
|
||||
# figure out completely tracked params file, and ignore specific keys
|
||||
wholly_tracked = set()
|
||||
for key in s_list:
|
||||
if not isinstance(key, dict):
|
||||
continue
|
||||
wholly_tracked.update(k for k, params in key.items() if not params)
|
||||
|
||||
for key in s_list:
|
||||
if isinstance(key, str):
|
||||
d[default_file].append(key)
|
||||
if default_file not in wholly_tracked:
|
||||
d[default_file].append(key)
|
||||
continue
|
||||
|
||||
if not isinstance(key, dict):
|
||||
msg = "Only list of str/dict is supported. Got: "
|
||||
msg += f"'{type(key).__name__}'."
|
||||
raise ValueError(msg)
|
||||
|
||||
for k, params in key.items():
|
||||
if k in wholly_tracked:
|
||||
d[k] = []
|
||||
continue
|
||||
if not isinstance(params, list):
|
||||
msg = "Expected list of params for custom params file "
|
||||
msg += f"'{k}', got '{type(params).__name__}'."
|
||||
|
||||
+62
-78
@@ -1,14 +1,15 @@
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import typing
|
||||
from collections import defaultdict
|
||||
from typing import Dict
|
||||
|
||||
import dpath.util
|
||||
from voluptuous import Any
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.utils.serialize import LOADERS, ParseError
|
||||
from dvc_objects.hash_info import HashInfo
|
||||
|
||||
from .base import Dependency
|
||||
|
||||
@@ -37,51 +38,82 @@ class ParamsDependency(Dependency):
|
||||
DEFAULT_PARAMS_FILE = "params.yaml"
|
||||
|
||||
def __init__(self, stage, path, params=None, repo=None):
|
||||
info = {}
|
||||
self.params = params or []
|
||||
if params:
|
||||
if isinstance(params, list):
|
||||
self.params = params
|
||||
else:
|
||||
assert isinstance(params, dict)
|
||||
self.params = list(params.keys())
|
||||
info = {self.PARAM_PARAMS: params}
|
||||
|
||||
super().__init__(
|
||||
stage,
|
||||
path
|
||||
or os.path.join(stage.repo.root_dir, self.DEFAULT_PARAMS_FILE),
|
||||
info=info,
|
||||
repo=repo,
|
||||
self.params = list(params) if params else []
|
||||
info = (
|
||||
{self.PARAM_PARAMS: params} if isinstance(params, dict) else None
|
||||
)
|
||||
repo = repo or stage.repo
|
||||
path = path or os.path.join(repo.root_dir, self.DEFAULT_PARAMS_FILE)
|
||||
super().__init__(stage, path, info=info, repo=repo)
|
||||
|
||||
def dumpd(self):
|
||||
ret = super().dumpd()
|
||||
if not self.hash_info:
|
||||
ret[self.PARAM_PARAMS] = self.params
|
||||
ret[self.PARAM_PARAMS] = self.params or {}
|
||||
return ret
|
||||
|
||||
def fill_values(self, values=None):
|
||||
"""Load params values dynamically."""
|
||||
if not values:
|
||||
if values is None:
|
||||
return
|
||||
|
||||
info = {}
|
||||
if not self.params:
|
||||
info.update(values)
|
||||
for param in self.params:
|
||||
if param in values:
|
||||
info[param] = values[param]
|
||||
self.hash_info = HashInfo(self.PARAM_PARAMS, info)
|
||||
|
||||
def workspace_status(self):
|
||||
status = super().workspace_status()
|
||||
def read_params(
|
||||
self, flatten: bool = True, **kwargs: typing.Any
|
||||
) -> Dict[str, typing.Any]:
|
||||
try:
|
||||
config = self.read_file()
|
||||
except MissingParamsFile:
|
||||
config = {}
|
||||
|
||||
if status.get(str(self)) == "deleted":
|
||||
return status
|
||||
if not self.params:
|
||||
return config
|
||||
|
||||
ret = {}
|
||||
if flatten:
|
||||
for param in self.params:
|
||||
try:
|
||||
ret[param] = dpath.util.get(config, param, separator=".")
|
||||
except KeyError:
|
||||
continue
|
||||
return ret
|
||||
|
||||
from dpath.util import merge
|
||||
|
||||
for param in self.params:
|
||||
merge(
|
||||
ret,
|
||||
dpath.util.search(config, param, separator="."),
|
||||
separator=".",
|
||||
)
|
||||
return ret
|
||||
|
||||
def workspace_status(self):
|
||||
if not self.exists:
|
||||
return {str(self): "deleted"}
|
||||
if self.hash_info.value is None:
|
||||
return {str(self): "new"}
|
||||
|
||||
from funcy import ldistinct
|
||||
|
||||
status = defaultdict(dict)
|
||||
info = self.hash_info.value if self.hash_info else {}
|
||||
actual = self.read_params()
|
||||
for param in self.params:
|
||||
if param not in actual.keys():
|
||||
|
||||
# NOTE: we want to preserve the order of params as specified in the
|
||||
# status. In case of tracking the whole file, the order is top-level
|
||||
# keys in the file and then the keys in the `info` from `dvc.lock`
|
||||
# (which are alphabetically sorted).
|
||||
params = self.params or ldistinct([*actual.keys(), *info.keys()])
|
||||
for param in params:
|
||||
if param not in actual:
|
||||
st = "deleted"
|
||||
elif param not in info:
|
||||
st = "new"
|
||||
@@ -100,29 +132,17 @@ class ParamsDependency(Dependency):
|
||||
|
||||
def validate_filepath(self):
|
||||
if not self.exists:
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), str(self)
|
||||
)
|
||||
raise MissingParamsFile(f"Parameters file '{self}' does not exist")
|
||||
if self.isdir():
|
||||
raise IsADirectoryError(
|
||||
errno.EISDIR, os.strerror(errno.EISDIR), str(self)
|
||||
raise ParamsIsADirectoryError(
|
||||
f"'{self}' is a directory, expected a parameters file"
|
||||
)
|
||||
|
||||
def read_file(self):
|
||||
_, ext = os.path.splitext(self.fs_path)
|
||||
loader = LOADERS[ext]
|
||||
|
||||
try:
|
||||
self.validate_filepath()
|
||||
except FileNotFoundError as exc:
|
||||
raise MissingParamsFile(
|
||||
f"Parameters file '{self}' does not exist"
|
||||
) from exc
|
||||
except IsADirectoryError as exc:
|
||||
raise ParamsIsADirectoryError(
|
||||
f"'{self}' is a directory, expected a parameters file"
|
||||
) from exc
|
||||
|
||||
self.validate_filepath()
|
||||
try:
|
||||
return loader(self.fs_path, fs=self.repo.fs)
|
||||
except ParseError as exc:
|
||||
@@ -130,35 +150,6 @@ class ParamsDependency(Dependency):
|
||||
f"Unable to read parameters from '{self}'"
|
||||
) from exc
|
||||
|
||||
def _read(self):
|
||||
try:
|
||||
return self.read_file()
|
||||
except MissingParamsFile:
|
||||
return {}
|
||||
|
||||
def read_params_d(self, **kwargs):
|
||||
config = self._read()
|
||||
|
||||
ret = {}
|
||||
for param in self.params:
|
||||
dpath.util.merge(
|
||||
ret,
|
||||
dpath.util.search(config, param, separator="."),
|
||||
separator=".",
|
||||
)
|
||||
return ret
|
||||
|
||||
def read_params(self):
|
||||
config = self._read()
|
||||
|
||||
ret = {}
|
||||
for param in self.params:
|
||||
try:
|
||||
ret[param] = dpath.util.get(config, param, separator=".")
|
||||
except KeyError:
|
||||
pass
|
||||
return ret
|
||||
|
||||
def get_hash(self):
|
||||
info = self.read_params()
|
||||
|
||||
@@ -179,12 +170,5 @@ class ParamsDependency(Dependency):
|
||||
if not self.isfile and not self.isdir:
|
||||
raise self.IsNotFileOrDirError(self)
|
||||
|
||||
if self.is_empty:
|
||||
logger.warning(f"'{self}' is empty.")
|
||||
|
||||
self.ignore()
|
||||
|
||||
if self.metric or self.plot:
|
||||
self.verify_metric()
|
||||
|
||||
self.hash_info = self.get_hash()
|
||||
|
||||
+22
-19
@@ -5,12 +5,14 @@ from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple
|
||||
|
||||
from voluptuous import Required
|
||||
|
||||
from dvc.prompt import confirm
|
||||
|
||||
from .base import Dependency
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc.objects.file import HashFile
|
||||
from dvc_objects.db import ObjectDB
|
||||
from dvc_objects.file import HashFile
|
||||
from dvc_objects.hash_info import HashInfo
|
||||
|
||||
|
||||
class RepoDependency(Dependency):
|
||||
@@ -61,7 +63,7 @@ class RepoDependency(Dependency):
|
||||
return {self.PARAM_PATH: self.def_path, self.PARAM_REPO: self.def_repo}
|
||||
|
||||
def download(self, to, jobs=None):
|
||||
from dvc.data.checkout import checkout
|
||||
from dvc_data.checkout import checkout
|
||||
|
||||
for odb, objs in self.get_used_objs().items():
|
||||
self.repo.cloud.pull(objs, jobs=jobs, odb=odb)
|
||||
@@ -72,8 +74,9 @@ class RepoDependency(Dependency):
|
||||
to.fs,
|
||||
obj,
|
||||
self.repo.odb.local,
|
||||
dvcignore=None,
|
||||
ignore=None,
|
||||
state=self.repo.state,
|
||||
prompt=confirm,
|
||||
)
|
||||
|
||||
def update(self, rev=None):
|
||||
@@ -98,9 +101,10 @@ class RepoDependency(Dependency):
|
||||
self, obj_only=False, **kwargs
|
||||
) -> Tuple[Dict[Optional["ObjectDB"], Set["HashInfo"]], "HashFile"]:
|
||||
from dvc.config import NoRemoteError
|
||||
from dvc.data.stage import stage
|
||||
from dvc.data.tree import Tree
|
||||
from dvc.exceptions import NoOutputOrStageError, PathMissingError
|
||||
from dvc.utils import as_posix
|
||||
from dvc_data.stage import stage
|
||||
from dvc_data.tree import Tree, TreeError
|
||||
|
||||
local_odb = self.repo.odb.local
|
||||
locked = kwargs.pop("locked", True)
|
||||
@@ -112,11 +116,10 @@ class RepoDependency(Dependency):
|
||||
if locked and self.def_repo.get(self.PARAM_REV_LOCK) is None:
|
||||
self.def_repo[self.PARAM_REV_LOCK] = rev
|
||||
|
||||
path = os.path.abspath(os.path.join(repo.root_dir, self.def_path))
|
||||
if not obj_only:
|
||||
try:
|
||||
for odb, obj_ids in repo.used_objs(
|
||||
[path],
|
||||
[os.path.join(repo.root_dir, self.def_path)],
|
||||
force=True,
|
||||
jobs=kwargs.get("jobs"),
|
||||
recursive=True,
|
||||
@@ -132,11 +135,11 @@ class RepoDependency(Dependency):
|
||||
try:
|
||||
staging, _, staged_obj = stage(
|
||||
local_odb,
|
||||
path,
|
||||
repo.repo_fs,
|
||||
as_posix(self.def_path),
|
||||
repo.dvcfs,
|
||||
local_odb.fs.PARAM_CHECKSUM,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
except (FileNotFoundError, TreeError) as exc:
|
||||
raise PathMissingError(
|
||||
self.def_path, self.def_repo[self.PARAM_URL]
|
||||
) from exc
|
||||
@@ -150,10 +153,10 @@ class RepoDependency(Dependency):
|
||||
return used_obj_ids, staged_obj
|
||||
|
||||
def _check_circular_import(self, odb, obj_ids):
|
||||
from dvc.data.db.reference import ReferenceObjectDB
|
||||
from dvc.data.tree import Tree
|
||||
from dvc.exceptions import CircularImportError
|
||||
from dvc.fs.repo import RepoFileSystem
|
||||
from dvc.fs.dvc import DvcFileSystem
|
||||
from dvc_data.db.reference import ReferenceObjectDB
|
||||
from dvc_data.tree import Tree
|
||||
|
||||
if not isinstance(odb, ReferenceObjectDB):
|
||||
return
|
||||
@@ -168,21 +171,21 @@ class RepoDependency(Dependency):
|
||||
|
||||
checked_urls = set()
|
||||
for obj in iter_objs():
|
||||
if not isinstance(obj.fs, RepoFileSystem):
|
||||
if not isinstance(obj.fs, DvcFileSystem):
|
||||
continue
|
||||
if (
|
||||
obj.fs.repo_url in checked_urls
|
||||
or obj.fs.root_dir in checked_urls
|
||||
or obj.fs.repo.root_dir in checked_urls
|
||||
):
|
||||
continue
|
||||
self_url = self.repo.url or self.repo.root_dir
|
||||
if (
|
||||
obj.fs.repo_url is not None
|
||||
and obj.fs.repo_url == self_url
|
||||
or obj.fs.root_dir == self.repo.root_dir
|
||||
or obj.fs.repo.root_dir == self.repo.root_dir
|
||||
):
|
||||
raise CircularImportError(self, obj.fs.repo_url, self_url)
|
||||
checked_urls.update([obj.fs.repo_url, obj.fs.root_dir])
|
||||
checked_urls.update([obj.fs.repo_url, obj.fs.repo.root_dir])
|
||||
|
||||
def get_obj(self, filter_info=None, **kwargs):
|
||||
locked = kwargs.get("locked", True)
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ def is_lock_file(path):
|
||||
|
||||
|
||||
def is_git_ignored(repo, path):
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
from dvc.fs import LocalFileSystem
|
||||
from dvc.scm import NoSCMError
|
||||
|
||||
try:
|
||||
|
||||
+1
-10
@@ -1,4 +1,5 @@
|
||||
"""Exceptions raised by the dvc."""
|
||||
from dvc.utils import format_link
|
||||
|
||||
|
||||
class DvcException(Exception):
|
||||
@@ -201,14 +202,6 @@ class FileMissingError(DvcException):
|
||||
)
|
||||
|
||||
|
||||
class DvcIgnoreInCollectedDirError(DvcException):
|
||||
def __init__(self, ignore_dirname):
|
||||
super().__init__(
|
||||
".dvcignore file should not be in collected dir path: "
|
||||
"'{}'".format(ignore_dirname)
|
||||
)
|
||||
|
||||
|
||||
class FileTransferError(DvcException):
|
||||
_METHOD = "transfer"
|
||||
|
||||
@@ -287,7 +280,6 @@ class PathMissingError(DvcException):
|
||||
|
||||
class RemoteCacheRequiredError(DvcException):
|
||||
def __init__(self, scheme, fs_path):
|
||||
from dvc.utils import format_link
|
||||
|
||||
super().__init__(
|
||||
(
|
||||
@@ -323,7 +315,6 @@ class MergeError(DvcException):
|
||||
|
||||
|
||||
class CacheLinkError(DvcException):
|
||||
from dvc.utils import format_link
|
||||
|
||||
SUPPORT_LINK = "See {} for more information.".format(
|
||||
format_link(
|
||||
|
||||
+46
-27
@@ -3,7 +3,7 @@ import os
|
||||
import tempfile
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict
|
||||
from typing import TYPE_CHECKING, Dict
|
||||
|
||||
from funcy import retry, wrap_with
|
||||
|
||||
@@ -19,6 +19,9 @@ from dvc.repo import Repo
|
||||
from dvc.scm import CloneError, map_scm_exception
|
||||
from dvc.utils import relpath
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from scmrepo import Git
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -30,7 +33,7 @@ def external_repo(
|
||||
from scmrepo.git import Git
|
||||
|
||||
from dvc.config import NoRemoteError
|
||||
from dvc.fs.git import GitFileSystem
|
||||
from dvc.fs import GitFileSystem
|
||||
|
||||
logger.debug("Creating external repo %s@%s", url, rev)
|
||||
path = _cached_clone(url, rev, for_write=for_write)
|
||||
@@ -47,19 +50,21 @@ def external_repo(
|
||||
config.update(cache_config)
|
||||
|
||||
if for_write:
|
||||
scm = None
|
||||
root_dir = path
|
||||
fs = None
|
||||
else:
|
||||
root_dir = os.path.realpath(path)
|
||||
scm = Git(root_dir)
|
||||
scm = Git(path)
|
||||
fs = GitFileSystem(scm=scm, rev=rev)
|
||||
root_dir = "/"
|
||||
|
||||
repo_kwargs = dict(
|
||||
root_dir=root_dir,
|
||||
url=url,
|
||||
fs=fs,
|
||||
config=config,
|
||||
repo_factory=erepo_factory(url, cache_config),
|
||||
repo_factory=erepo_factory(url, root_dir, cache_config),
|
||||
scm=scm,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -89,14 +94,16 @@ def external_repo(
|
||||
_remove(path)
|
||||
|
||||
|
||||
def erepo_factory(url, cache_config, *args, **kwargs):
|
||||
def make_repo(path, **_kwargs):
|
||||
def erepo_factory(url, root_dir, cache_config):
|
||||
from dvc.fs import localfs
|
||||
|
||||
def make_repo(path, fs=None, **_kwargs):
|
||||
_config = cache_config.copy()
|
||||
if os.path.isdir(url):
|
||||
rel = os.path.relpath(path, _kwargs["fs"].fs_args["scm"].root_dir)
|
||||
repo_path = os.path.join(url, rel)
|
||||
fs = fs or localfs
|
||||
repo_path = os.path.join(url, *fs.path.relparts(path, root_dir))
|
||||
_config.update(_get_remote_config(repo_path))
|
||||
return Repo(path, config=_config, **_kwargs)
|
||||
return Repo(path, fs=fs, config=_config, **_kwargs)
|
||||
|
||||
return make_repo
|
||||
|
||||
@@ -201,12 +208,12 @@ def _clone_default_branch(url, rev, for_write=False):
|
||||
# given repo URL it is easier/safer for us to work with
|
||||
# full clones in this case.
|
||||
logger.debug("erepo: unshallowing clone for '%s'", url)
|
||||
_unshallow(git)
|
||||
_pull(git, unshallow=True)
|
||||
shallow = False
|
||||
CLONES[url] = (clone_path, shallow)
|
||||
else:
|
||||
logger.debug("erepo: git pull '%s'", url)
|
||||
git.pull()
|
||||
_pull(git)
|
||||
else:
|
||||
from dvc.scm import clone
|
||||
|
||||
@@ -217,12 +224,17 @@ def _clone_default_branch(url, rev, for_write=False):
|
||||
|
||||
try:
|
||||
git = clone(url, clone_path, shallow_branch=rev)
|
||||
shallow = True
|
||||
logger.debug(
|
||||
"erepo: using shallow clone for branch '%s'", rev
|
||||
shallow = os.path.exists(
|
||||
os.path.join(clone_path, Git.GIT_DIR, "shallow")
|
||||
)
|
||||
if shallow:
|
||||
logger.debug(
|
||||
"erepo: using shallow clone for branch '%s'", rev
|
||||
)
|
||||
except CloneError:
|
||||
pass
|
||||
git_dir = os.path.join(clone_path, ".git")
|
||||
if os.path.exists(git_dir):
|
||||
_remove(git_dir)
|
||||
if not git:
|
||||
git = clone(url, clone_path)
|
||||
shallow = False
|
||||
@@ -234,17 +246,24 @@ def _clone_default_branch(url, rev, for_write=False):
|
||||
return clone_path, shallow
|
||||
|
||||
|
||||
def _unshallow(git):
|
||||
if git.gitpython.repo.head.is_detached:
|
||||
# If this is a detached head (i.e. we shallow cloned a tag) switch to
|
||||
# the default branch
|
||||
origin_refs = git.gitpython.repo.remotes["origin"].refs
|
||||
ref = origin_refs["HEAD"].reference
|
||||
branch_name = ref.name.split("/")[-1]
|
||||
branch = git.gitpython.repo.create_head(branch_name, ref)
|
||||
branch.set_tracking_branch(ref)
|
||||
branch.checkout()
|
||||
git.pull(unshallow=True)
|
||||
def _pull(git: "Git", unshallow: bool = False):
|
||||
from dvc.repo.experiments.utils import fetch_all_exps
|
||||
|
||||
git.fetch(unshallow=unshallow)
|
||||
_merge_upstream(git)
|
||||
fetch_all_exps(git, "origin")
|
||||
|
||||
|
||||
def _merge_upstream(git: "Git"):
|
||||
from scmrepo.exceptions import SCMError
|
||||
|
||||
try:
|
||||
branch = git.active_branch()
|
||||
upstream = f"refs/remotes/origin/{branch}"
|
||||
if git.get_ref(upstream):
|
||||
git.merge(upstream)
|
||||
except SCMError:
|
||||
pass
|
||||
|
||||
|
||||
def _git_checkout(repo_path, rev):
|
||||
|
||||
+48
-37
@@ -1,41 +1,47 @@
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ..scheme import Schemes
|
||||
from . import utils # noqa: F401
|
||||
from .azure import AzureFileSystem
|
||||
from .gdrive import GDriveFileSystem
|
||||
from .gs import GSFileSystem
|
||||
from .hdfs import HDFSFileSystem
|
||||
from .http import HTTPFileSystem
|
||||
from .https import HTTPSFileSystem
|
||||
from .local import LocalFileSystem
|
||||
from .oss import OSSFileSystem
|
||||
from .s3 import S3FileSystem
|
||||
from .ssh import SSHFileSystem
|
||||
from .webdav import WebDAVFileSystem, WebDAVSFileSystem
|
||||
from .webhdfs import WebHDFSFileSystem
|
||||
# pylint: disable=unused-import
|
||||
from dvc_objects.fs import utils # noqa: F401
|
||||
from dvc_objects.fs import ( # noqa: F401
|
||||
FS_MAP,
|
||||
AzureFileSystem,
|
||||
GDriveFileSystem,
|
||||
GSFileSystem,
|
||||
HDFSFileSystem,
|
||||
HTTPFileSystem,
|
||||
HTTPSFileSystem,
|
||||
LocalFileSystem,
|
||||
MemoryFileSystem,
|
||||
OSSFileSystem,
|
||||
S3FileSystem,
|
||||
Schemes,
|
||||
SSHFileSystem,
|
||||
WebDAVFileSystem,
|
||||
WebDAVSFileSystem,
|
||||
WebHDFSFileSystem,
|
||||
generic,
|
||||
get_fs_cls,
|
||||
system,
|
||||
)
|
||||
from dvc_objects.fs.base import AnyFSPath, FileSystem # noqa: F401
|
||||
from dvc_objects.fs.errors import ( # noqa: F401
|
||||
AuthError,
|
||||
ConfigError,
|
||||
RemoteMissingDepsError,
|
||||
)
|
||||
from dvc_objects.fs.implementations.azure import AzureAuthError # noqa: F401
|
||||
from dvc_objects.fs.implementations.gdrive import GDriveAuthError # noqa: F401
|
||||
from dvc_objects.fs.implementations.local import localfs # noqa: F401
|
||||
from dvc_objects.fs.implementations.ssh import ( # noqa: F401
|
||||
DEFAULT_PORT as DEFAULT_SSH_PORT,
|
||||
)
|
||||
from dvc_objects.fs.path import Path # noqa: F401
|
||||
|
||||
FS_MAP = {
|
||||
Schemes.AZURE: AzureFileSystem,
|
||||
Schemes.GDRIVE: GDriveFileSystem,
|
||||
Schemes.GS: GSFileSystem,
|
||||
Schemes.HDFS: HDFSFileSystem,
|
||||
Schemes.WEBHDFS: WebHDFSFileSystem,
|
||||
Schemes.HTTP: HTTPFileSystem,
|
||||
Schemes.HTTPS: HTTPSFileSystem,
|
||||
Schemes.S3: S3FileSystem,
|
||||
Schemes.SSH: SSHFileSystem,
|
||||
Schemes.OSS: OSSFileSystem,
|
||||
Schemes.WEBDAV: WebDAVFileSystem,
|
||||
Schemes.WEBDAVS: WebDAVSFileSystem,
|
||||
# NOTE: LocalFileSystem is the default
|
||||
}
|
||||
from .data import DataFileSystem # noqa: F401
|
||||
from .dvc import DvcFileSystem # noqa: F401
|
||||
from .git import GitFileSystem # noqa: F401
|
||||
|
||||
|
||||
def get_fs_cls(remote_conf, scheme=None):
|
||||
if not scheme:
|
||||
scheme = urlparse(remote_conf["url"]).scheme
|
||||
return FS_MAP.get(scheme, LocalFileSystem)
|
||||
# pylint: enable=unused-import
|
||||
|
||||
|
||||
def get_fs_config(repo, config, **kwargs):
|
||||
@@ -86,7 +92,7 @@ def _resolve_remote_refs(repo, config, remote_conf):
|
||||
|
||||
|
||||
def get_cloud_fs(repo, **kwargs):
|
||||
from dvc.config import ConfigError
|
||||
from dvc.config import ConfigError as RepoConfigError
|
||||
from dvc.config_schema import SCHEMA, Invalid
|
||||
|
||||
repo_config = repo.config if repo else {}
|
||||
@@ -96,7 +102,7 @@ def get_cloud_fs(repo, **kwargs):
|
||||
try:
|
||||
remote_conf = SCHEMA["remote"][str](remote_conf)
|
||||
except Invalid as exc:
|
||||
raise ConfigError(str(exc)) from None
|
||||
raise RepoConfigError(str(exc)) from None
|
||||
|
||||
if "jobs" not in remote_conf:
|
||||
jobs = core_config.get("jobs")
|
||||
@@ -114,7 +120,12 @@ def get_cloud_fs(repo, **kwargs):
|
||||
remote_conf["gdrive_credentials_tmp_dir"] = repo.tmp_dir
|
||||
|
||||
url = remote_conf.pop("url")
|
||||
fs_path = cls._strip_protocol(url) # pylint:disable=protected-access
|
||||
if issubclass(cls, WebDAVFileSystem):
|
||||
# For WebDAVFileSystem, provided url is the base path itself, so it
|
||||
# should be treated as being a root path.
|
||||
fs_path = cls.root_marker
|
||||
else:
|
||||
fs_path = 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
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import fsspec
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
|
||||
class FsspecCallback(fsspec.Callback):
|
||||
def __init__(self, progress_bar):
|
||||
self.progress_bar = progress_bar
|
||||
super().__init__()
|
||||
|
||||
def set_size(self, size):
|
||||
if size is not None:
|
||||
self.progress_bar.total = size
|
||||
self.progress_bar.refresh()
|
||||
super().set_size(size)
|
||||
|
||||
def relative_update(self, inc=1):
|
||||
self.progress_bar.update(inc)
|
||||
super().relative_update(inc)
|
||||
|
||||
def absolute_update(self, value):
|
||||
self.progress_bar.update_to(value)
|
||||
super().absolute_update(value)
|
||||
|
||||
@staticmethod
|
||||
def wrap_fn(cb, fn):
|
||||
def wrapped(*args, **kwargs):
|
||||
res = fn(*args, **kwargs)
|
||||
cb.relative_update()
|
||||
return res
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def tdqm_or_callback_wrapped(
|
||||
fobj, method, total, callback=None, **pbar_kwargs
|
||||
):
|
||||
if callback:
|
||||
from funcy import nullcontext
|
||||
from tqdm.utils import CallbackIOWrapper
|
||||
|
||||
callback.set_size(total)
|
||||
wrapper = CallbackIOWrapper(callback.relative_update, fobj, method)
|
||||
return nullcontext(wrapper)
|
||||
|
||||
return Tqdm.wrapattr(fobj, method, total=total, bytes=True, **pbar_kwargs)
|
||||
|
||||
|
||||
DEFAULT_CALLBACK = fsspec.callbacks.NoOpCallback()
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from fsspec.asyn import fsspec_loop
|
||||
from fsspec.utils import infer_storage_options
|
||||
from funcy import cached_property, memoize, wrap_prop
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils import format_link
|
||||
|
||||
from .fsspec_wrapper import CallbackMixin, ObjectFSWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_DEFAULT_CREDS_STEPS = (
|
||||
"https://azuresdkdocs.blob.core.windows.net/$web/python/"
|
||||
"azure-identity/1.4.0/azure.identity.html#azure.identity"
|
||||
".DefaultAzureCredential"
|
||||
)
|
||||
|
||||
|
||||
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(CallbackMixin, ObjectFSWrapper):
|
||||
scheme = Schemes.AZURE
|
||||
PARAM_CHECKSUM = "etag"
|
||||
REQUIRES = {
|
||||
"adlfs": "adlfs",
|
||||
"knack": "knack",
|
||||
"azure-identity": "azure.identity",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str):
|
||||
opts = infer_storage_options(path)
|
||||
if opts.get("host"):
|
||||
return "{host}{path}".format(**opts)
|
||||
|
||||
return _az_config().get("storage", "container_name", None)
|
||||
|
||||
def unstrip_protocol(self, path: str) -> str:
|
||||
return "azure://" + path.lstrip("/")
|
||||
|
||||
@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
|
||||
|
||||
# Disable spam from failed cred types for DefaultAzureCredential
|
||||
logging.getLogger("azure.identity.aio").setLevel(logging.ERROR)
|
||||
|
||||
login_info = {}
|
||||
login_info["connection_string"] = config.get(
|
||||
"connection_string",
|
||||
_az_config().get("storage", "connection_string", None),
|
||||
)
|
||||
login_info["account_name"] = config.get(
|
||||
"account_name", _az_config().get("storage", "account", None)
|
||||
)
|
||||
login_info["account_key"] = config.get(
|
||||
"account_key", _az_config().get("storage", "key", None)
|
||||
)
|
||||
login_info["sas_token"] = config.get(
|
||||
"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 (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)
|
||||
):
|
||||
with fsspec_loop():
|
||||
login_info["credential"] = DefaultAzureCredential(
|
||||
exclude_interactive_browser_credential=False,
|
||||
exclude_environment_credential=config.get(
|
||||
"exclude_environment_credential", False
|
||||
),
|
||||
exclude_visual_studio_code_credential=config.get(
|
||||
"exclude_visual_studio_code_credential", False
|
||||
),
|
||||
exclude_shared_token_cache_credential=config.get(
|
||||
"exclude_shared_token_cache_credential", False
|
||||
),
|
||||
exclude_managed_identity_credential=config.get(
|
||||
"exclude_managed_identity_credential", False
|
||||
),
|
||||
)
|
||||
|
||||
for login_method, required_keys in [ # noqa
|
||||
("connection string", ["connection_string"]),
|
||||
(
|
||||
"AD service principal",
|
||||
["tenant_id", "client_id", "client_secret"],
|
||||
),
|
||||
("account key", ["account_name", "account_key"]),
|
||||
("SAS token", ["account_name", "sas_token"]),
|
||||
(
|
||||
f"default credentials ({_DEFAULT_CREDS_STEPS})",
|
||||
["account_name", "credential"],
|
||||
),
|
||||
("anonymous login", ["account_name"]),
|
||||
]:
|
||||
if all(login_info.get(key) is not None for key in required_keys):
|
||||
break
|
||||
else:
|
||||
login_method = None
|
||||
|
||||
self.login_method = login_method
|
||||
return login_info
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from adlfs import AzureBlobFileSystem
|
||||
from azure.core.exceptions import AzureError
|
||||
|
||||
try:
|
||||
return AzureBlobFileSystem(**self.fs_args)
|
||||
except (ValueError, AzureError) as e:
|
||||
raise AzureAuthError(
|
||||
f"Authentication to Azure Blob Storage via {self.login_method}"
|
||||
" failed.\nLearn more about configuration settings at"
|
||||
f" {format_link('https://man.dvc.org/remote/modify')}"
|
||||
) from e
|
||||
-362
@@ -1,362 +0,0 @@
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from functools import partialmethod
|
||||
from multiprocessing import cpu_count
|
||||
from typing import ClassVar, Dict, Optional
|
||||
|
||||
from funcy import cached_property
|
||||
from tqdm.utils import CallbackIOWrapper
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.fs._callback import DEFAULT_CALLBACK, FsspecCallback
|
||||
from dvc.ui import ui
|
||||
from dvc.utils import tmp_fname
|
||||
from dvc.utils.fs import makedirs, move
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemoteActionNotImplemented(DvcException):
|
||||
def __init__(self, action, scheme):
|
||||
m = f"{action} is not supported for {scheme} remotes"
|
||||
super().__init__(m)
|
||||
|
||||
|
||||
class RemoteMissingDepsError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
class FileSystem:
|
||||
sep = "/"
|
||||
|
||||
scheme = "base"
|
||||
REQUIRES: ClassVar[Dict[str, str]] = {}
|
||||
_JOBS = 4 * cpu_count()
|
||||
|
||||
HASH_JOBS = max(1, min(4, cpu_count() // 2))
|
||||
LIST_OBJECT_PAGE_SIZE = 1000
|
||||
TRAVERSE_WEIGHT_MULTIPLIER = 5
|
||||
TRAVERSE_PREFIX_LEN = 3
|
||||
TRAVERSE_THRESHOLD_SIZE = 500000
|
||||
CAN_TRAVERSE = True
|
||||
|
||||
# Needed for some providers, and http open()
|
||||
CHUNK_SIZE = 64 * 1024 * 1024 # 64 MiB
|
||||
|
||||
PARAM_CHECKSUM: ClassVar[Optional[str]] = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self._check_requires(**kwargs)
|
||||
|
||||
self.jobs = kwargs.get("jobs") or self._JOBS
|
||||
self.hash_jobs = kwargs.get("checksum_jobs") or self.HASH_JOBS
|
||||
self._config = kwargs
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return self._config
|
||||
|
||||
@cached_property
|
||||
def path(self):
|
||||
from .path import Path
|
||||
|
||||
return Path(self.sep)
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str):
|
||||
return path
|
||||
|
||||
def unstrip_protocol(self, path):
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath): # pylint:disable=unused-argument
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def get_missing_deps(cls):
|
||||
import importlib
|
||||
|
||||
missing = []
|
||||
for package, module in cls.REQUIRES.items():
|
||||
try:
|
||||
importlib.import_module(module)
|
||||
except ImportError:
|
||||
missing.append(package)
|
||||
|
||||
return missing
|
||||
|
||||
def _check_requires(self, **kwargs):
|
||||
from ..scheme import Schemes
|
||||
from ..utils import format_link
|
||||
from ..utils.pkg import PKG
|
||||
|
||||
missing = self.get_missing_deps()
|
||||
if not missing:
|
||||
return
|
||||
|
||||
url = kwargs.get("url", f"{self.scheme}://")
|
||||
|
||||
scheme = self.scheme
|
||||
if scheme == Schemes.WEBDAVS:
|
||||
scheme = Schemes.WEBDAV
|
||||
|
||||
by_pkg = {
|
||||
"pip": f"pip install 'dvc[{scheme}]'",
|
||||
"conda": f"conda install -c conda-forge dvc-{scheme}",
|
||||
}
|
||||
|
||||
cmd = by_pkg.get(PKG)
|
||||
if cmd:
|
||||
link = format_link("https://dvc.org/doc/install")
|
||||
hint = (
|
||||
f"To install dvc with those dependencies, run:\n"
|
||||
"\n"
|
||||
f"\t{cmd}\n"
|
||||
"\n"
|
||||
f"See {link} for more info."
|
||||
)
|
||||
else:
|
||||
link = format_link("https://github.com/iterative/dvc/issues")
|
||||
hint = f"Please report this bug to {link}. Thank you!"
|
||||
|
||||
raise RemoteMissingDepsError(
|
||||
f"URL '{url}' is supported but requires these missing "
|
||||
f"dependencies: {missing}. {hint}"
|
||||
)
|
||||
|
||||
def checksum(self, path) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def open(self, path, mode: str = "r", encoding: str = None, **kwargs):
|
||||
raise RemoteActionNotImplemented("open", self.scheme)
|
||||
|
||||
def exists(self, path) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
def isdir(self, path):
|
||||
"""Optional: Overwrite only if the remote has a way to distinguish
|
||||
between a directory and a file.
|
||||
"""
|
||||
return False
|
||||
|
||||
def isfile(self, path):
|
||||
"""Optional: Overwrite only if the remote has a way to distinguish
|
||||
between a directory and a file.
|
||||
"""
|
||||
return True
|
||||
|
||||
def iscopy(self, path):
|
||||
"""Check if this file is an independent copy."""
|
||||
return False # We can't be sure by default
|
||||
|
||||
def walk(self, top, topdown=True, onerror=None, **kwargs):
|
||||
"""Return a generator with (root, dirs, files)."""
|
||||
raise NotImplementedError
|
||||
|
||||
def find(self, path, prefix=None):
|
||||
"""Return a generator with `str`s to all the files.
|
||||
|
||||
Optional kwargs:
|
||||
prefix (bool): If true `path` will be treated as a prefix
|
||||
rather than directory path.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def is_empty(self, path):
|
||||
return False
|
||||
|
||||
def info(self, path):
|
||||
raise NotImplementedError
|
||||
|
||||
def getsize(self, path):
|
||||
return self.info(path).get("size")
|
||||
|
||||
def remove(self, path):
|
||||
raise RemoteActionNotImplemented("remove", self.scheme)
|
||||
|
||||
def makedirs(self, path, **kwargs):
|
||||
"""Optional: Implement only if the remote needs to create
|
||||
directories before copying/linking/moving data
|
||||
"""
|
||||
|
||||
def move(self, from_info, to_info):
|
||||
self.copy(from_info, to_info)
|
||||
self.remove(from_info)
|
||||
|
||||
def copy(self, from_info, to_info):
|
||||
raise RemoteActionNotImplemented("copy", self.scheme)
|
||||
|
||||
def symlink(self, from_info, to_info):
|
||||
raise RemoteActionNotImplemented("symlink", self.scheme)
|
||||
|
||||
def hardlink(self, from_info, to_info):
|
||||
raise RemoteActionNotImplemented("hardlink", self.scheme)
|
||||
|
||||
def reflink(self, from_info, to_info):
|
||||
raise RemoteActionNotImplemented("reflink", self.scheme)
|
||||
|
||||
# pylint: enable=unused-argument
|
||||
|
||||
def upload(
|
||||
self,
|
||||
from_info,
|
||||
to_info,
|
||||
total=None,
|
||||
desc=None,
|
||||
callback=None,
|
||||
no_progress_bar=False,
|
||||
**pbar_args,
|
||||
):
|
||||
is_file_obj = hasattr(from_info, "read")
|
||||
method = "upload_fobj" if is_file_obj else "put_file"
|
||||
if not hasattr(self, method):
|
||||
raise RemoteActionNotImplemented(method, self.scheme)
|
||||
|
||||
if not is_file_obj:
|
||||
from .local import localfs
|
||||
|
||||
desc = desc or localfs.path.name(from_info)
|
||||
|
||||
stack = contextlib.ExitStack()
|
||||
if not callback:
|
||||
pbar = ui.progress(
|
||||
desc=desc,
|
||||
disable=no_progress_bar,
|
||||
bytes=True,
|
||||
total=total or -1,
|
||||
**pbar_args,
|
||||
)
|
||||
stack.enter_context(pbar)
|
||||
callback = pbar.as_callback()
|
||||
if total:
|
||||
callback.set_size(total)
|
||||
|
||||
with stack:
|
||||
if is_file_obj:
|
||||
wrapped = CallbackIOWrapper(
|
||||
callback.relative_update, from_info
|
||||
)
|
||||
# `size` is used to provide hints to the WebdavFileSystem
|
||||
# for legacy servers.
|
||||
# pylint: disable=no-member
|
||||
return self.upload_fobj(wrapped, to_info, size=total)
|
||||
|
||||
logger.debug("Uploading '%s' to '%s'", from_info, to_info)
|
||||
# pylint: disable=no-member
|
||||
return self.put_file(
|
||||
os.fspath(from_info), to_info, callback=callback
|
||||
)
|
||||
|
||||
def download(
|
||||
self,
|
||||
from_info,
|
||||
to_info,
|
||||
name=None,
|
||||
callback=None,
|
||||
no_progress_bar=False,
|
||||
jobs=None,
|
||||
_only_file=False,
|
||||
**kwargs,
|
||||
):
|
||||
from .local import localfs
|
||||
|
||||
if not hasattr(self, "get_file"):
|
||||
raise RemoteActionNotImplemented("get_file", self.scheme)
|
||||
|
||||
download_dir = not _only_file and self.isdir(from_info)
|
||||
|
||||
desc = name or localfs.path.name(to_info)
|
||||
stack = contextlib.ExitStack()
|
||||
if not callback:
|
||||
pbar_kwargs = {"unit": "Files"} if download_dir else {}
|
||||
pbar = ui.progress(
|
||||
total=-1,
|
||||
desc="Downloading directory" if download_dir else desc,
|
||||
bytes=not download_dir,
|
||||
disable=no_progress_bar,
|
||||
**pbar_kwargs,
|
||||
)
|
||||
stack.enter_context(pbar)
|
||||
callback = pbar.as_callback()
|
||||
|
||||
with stack:
|
||||
if download_dir:
|
||||
return self._download_dir(
|
||||
from_info, to_info, callback=callback, jobs=jobs, **kwargs
|
||||
)
|
||||
return self._download_file(from_info, to_info, callback=callback)
|
||||
|
||||
download_file = partialmethod(download, _only_file=True)
|
||||
|
||||
def _download_dir(
|
||||
self,
|
||||
from_info,
|
||||
to_info,
|
||||
callback=DEFAULT_CALLBACK,
|
||||
jobs=None,
|
||||
**kwargs,
|
||||
):
|
||||
from .local import localfs
|
||||
|
||||
from_infos = list(self.find(from_info, **kwargs))
|
||||
if not from_infos:
|
||||
makedirs(to_info, exist_ok=True)
|
||||
return None
|
||||
|
||||
to_infos = (
|
||||
localfs.path.join(to_info, *self.path.relparts(info, from_info))
|
||||
for info in from_infos
|
||||
)
|
||||
callback.set_size(len(from_infos))
|
||||
|
||||
download_files = FsspecCallback.wrap_fn(callback, self._download_file)
|
||||
max_workers = jobs or self.jobs
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [
|
||||
executor.submit(download_files, from_info, to_info)
|
||||
for from_info, to_info in zip(from_infos, to_infos)
|
||||
]
|
||||
|
||||
# NOTE: unlike pulling/fetching cache, where we need to
|
||||
# download everything we can, not raising an error here might
|
||||
# turn very ugly, as the user might think that he has
|
||||
# downloaded a complete directory, while having a partial one,
|
||||
# which might cause unexpected results in his pipeline.
|
||||
for future in as_completed(futures):
|
||||
# NOTE: executor won't let us raise until all futures that
|
||||
# it has are finished, so we need to cancel them ourselves
|
||||
# before re-raising.
|
||||
exc = future.exception()
|
||||
if exc:
|
||||
for entry in futures:
|
||||
entry.cancel()
|
||||
raise exc
|
||||
|
||||
def _download_file(
|
||||
self,
|
||||
from_info,
|
||||
to_info,
|
||||
callback=DEFAULT_CALLBACK,
|
||||
):
|
||||
from .local import localfs
|
||||
|
||||
makedirs(localfs.path.parent(to_info), exist_ok=True)
|
||||
tmp_file = tmp_fname(to_info)
|
||||
|
||||
logger.debug("Downloading '%s' to '%s'", from_info, to_info)
|
||||
try:
|
||||
# noqa, pylint: disable=no-member
|
||||
self.get_file(from_info, tmp_file, callback=callback)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# do we need to rollback makedirs for previously not-existing
|
||||
# directories?
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.unlink(tmp_file)
|
||||
raise
|
||||
|
||||
move(tmp_file, to_info)
|
||||
@@ -0,0 +1,79 @@
|
||||
# pylint: disable=unused-import
|
||||
from contextlib import ExitStack
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
from dvc_objects.fs.callbacks import ( # noqa: F401
|
||||
DEFAULT_CALLBACK,
|
||||
Callback,
|
||||
TqdmCallback,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.ui._rich_progress import RichTransferProgress
|
||||
|
||||
|
||||
class RichCallback(Callback):
|
||||
def __init__(
|
||||
self,
|
||||
size: Optional[int] = None,
|
||||
value: int = 0,
|
||||
progress: "RichTransferProgress" = None,
|
||||
desc: str = None,
|
||||
bytes: bool = False, # pylint: disable=redefined-builtin
|
||||
unit: str = None,
|
||||
disable: bool = False,
|
||||
) -> None:
|
||||
self._progress = progress
|
||||
self.disable = disable
|
||||
self._task_kwargs = {
|
||||
"description": desc or "",
|
||||
"bytes": bytes,
|
||||
"unit": unit,
|
||||
"total": size or 0,
|
||||
"visible": False,
|
||||
"progress_type": None if bytes else "summary",
|
||||
}
|
||||
self._stack = ExitStack()
|
||||
super().__init__(size=size, value=value)
|
||||
|
||||
@cached_property
|
||||
def progress(self):
|
||||
from dvc.ui import ui
|
||||
from dvc.ui._rich_progress import RichTransferProgress
|
||||
|
||||
if self._progress is not None:
|
||||
return self._progress
|
||||
|
||||
progress = RichTransferProgress(
|
||||
transient=True,
|
||||
disable=self.disable,
|
||||
console=ui.error_console,
|
||||
)
|
||||
return self._stack.enter_context(progress)
|
||||
|
||||
@cached_property
|
||||
def task(self):
|
||||
return self.progress.add_task(**self._task_kwargs)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def close(self):
|
||||
self.progress.clear_task(self.task)
|
||||
self._stack.close()
|
||||
|
||||
def call(self, hook_name=None, **kwargs):
|
||||
self.progress.update(
|
||||
self.task,
|
||||
completed=self.value,
|
||||
total=self.size,
|
||||
visible=not self.disable,
|
||||
)
|
||||
|
||||
def branch(self, path_1, path_2, kwargs, child: Optional[Callback] = None):
|
||||
child = child or RichCallback(
|
||||
progress=self.progress, desc=path_1, bytes=True
|
||||
)
|
||||
return super().branch(path_1, path_2, kwargs, child=child)
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import logging
|
||||
import threading
|
||||
import typing
|
||||
|
||||
from fsspec import AbstractFileSystem
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc_objects.fs.base import FileSystem
|
||||
from dvc_objects.fs.callbacks import DEFAULT_CALLBACK
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from dvc.types import AnyPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _DataFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
|
||||
"""DVC repo fs.
|
||||
|
||||
Args:
|
||||
repo: DVC repo.
|
||||
"""
|
||||
|
||||
root_marker = "/"
|
||||
|
||||
def __init__(self, workspace=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.repo = kwargs["repo"]
|
||||
self.workspace = workspace or "repo"
|
||||
|
||||
@cached_property
|
||||
def path(self):
|
||||
from . import Path
|
||||
|
||||
def _getcwd():
|
||||
return self.root_marker
|
||||
|
||||
return Path(self.sep, getcwd=_getcwd)
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _get_key(self, path):
|
||||
if self.workspace != "repo":
|
||||
from . import get_cloud_fs
|
||||
|
||||
cls, kwargs, fs_path = get_cloud_fs(None, url=path)
|
||||
fs = cls(**kwargs)
|
||||
return (self.workspace, *fs.path.parts(fs_path))
|
||||
|
||||
path = self.path.abspath(path)
|
||||
if path == self.root_marker:
|
||||
return (self.workspace,)
|
||||
|
||||
key = self.path.relparts(path, self.root_marker)
|
||||
if key == (".") or key == (""):
|
||||
key = ()
|
||||
|
||||
return (self.workspace, *key)
|
||||
|
||||
def _get_fs_path(self, path: "AnyPath", remote=None):
|
||||
from dvc.config import NoRemoteError
|
||||
|
||||
info = self.info(path)
|
||||
if info["type"] == "directory":
|
||||
raise IsADirectoryError
|
||||
|
||||
value = info.get("md5")
|
||||
if not value:
|
||||
raise FileNotFoundError
|
||||
|
||||
cache_path = self.repo.odb.local.hash_to_path(value)
|
||||
|
||||
if self.repo.odb.local.fs.exists(cache_path):
|
||||
return self.repo.odb.local.fs, cache_path
|
||||
|
||||
try:
|
||||
remote_odb = self.repo.cloud.get_remote_odb(remote)
|
||||
except NoRemoteError as exc:
|
||||
raise FileNotFoundError from exc
|
||||
remote_fs_path = remote_odb.hash_to_path(value)
|
||||
return remote_odb.fs, remote_fs_path
|
||||
|
||||
def open( # type: ignore
|
||||
self, path: str, mode="r", encoding=None, **kwargs
|
||||
): # pylint: disable=arguments-renamed, arguments-differ
|
||||
fs, fspath = self._get_fs_path(path, **kwargs)
|
||||
return fs.open(fspath, mode=mode, encoding=encoding)
|
||||
|
||||
def ls(self, path, detail=True, **kwargs):
|
||||
info = self.info(path)
|
||||
if info["type"] != "directory":
|
||||
return [info] if detail else [path]
|
||||
|
||||
root_key = self._get_key(path)
|
||||
try:
|
||||
entries = [
|
||||
self.sep.join((path, name)) if path else name
|
||||
for name in self.repo.index.tree.ls(prefix=root_key)
|
||||
]
|
||||
except KeyError as exc:
|
||||
raise FileNotFoundError from exc
|
||||
|
||||
if not detail:
|
||||
return entries
|
||||
|
||||
return [self.info(epath) for epath in entries]
|
||||
|
||||
def isdvc(self, path, recursive=False, strict=True):
|
||||
try:
|
||||
info = self.info(path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
recurse = recursive or not strict
|
||||
return bool(info.get("outs") if recurse else info.get("isout"))
|
||||
|
||||
def info(self, path, **kwargs):
|
||||
from dvc_objects.meta import Meta
|
||||
|
||||
key = self._get_key(path)
|
||||
|
||||
try:
|
||||
outs = list(self.repo.index.tree.iteritems(key))
|
||||
except KeyError as exc:
|
||||
raise FileNotFoundError from exc
|
||||
|
||||
ret = {
|
||||
"type": "file",
|
||||
"size": 0,
|
||||
"isexec": False,
|
||||
"isdvc": False,
|
||||
"outs": outs,
|
||||
"name": path,
|
||||
}
|
||||
|
||||
if len(outs) > 1 and outs[0][0] != key:
|
||||
shortest = self.repo.index.tree.shortest_prefix(key)
|
||||
if shortest:
|
||||
assert shortest[1][1].isdir
|
||||
if len(shortest[0]) <= len(key):
|
||||
ret["isdvc"] = True
|
||||
|
||||
ret["type"] = "directory"
|
||||
return ret
|
||||
|
||||
item_key, (meta, hash_info) = outs[0]
|
||||
|
||||
meta = meta or Meta()
|
||||
|
||||
if key != item_key:
|
||||
assert item_key[: len(key)] == key
|
||||
ret["type"] = "directory"
|
||||
return ret
|
||||
|
||||
ret["size"] = meta.size
|
||||
ret["isexec"] = meta.isexec
|
||||
ret[hash_info.name] = hash_info.value
|
||||
ret["isdvc"] = True
|
||||
ret["isout"] = True
|
||||
ret["meta"] = meta
|
||||
if hash_info and hash_info.isdir:
|
||||
ret["type"] = "directory"
|
||||
return ret
|
||||
|
||||
def get_file( # pylint: disable=arguments-differ
|
||||
self, rpath, lpath, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
fs, path = self._get_fs_path(rpath)
|
||||
fs.get_file(path, lpath, callback=callback, **kwargs)
|
||||
|
||||
def checksum(self, path):
|
||||
info = self.info(path)
|
||||
md5 = info.get("md5")
|
||||
if md5:
|
||||
return md5
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DataFileSystem(FileSystem):
|
||||
protocol = "local"
|
||||
|
||||
PARAM_CHECKSUM = "md5"
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
return config
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
return _DataFileSystem(**self.fs_args)
|
||||
|
||||
def isdvc(self, path, **kwargs):
|
||||
return self.fs.isdvc(path, **kwargs)
|
||||
|
||||
@property
|
||||
def repo(self):
|
||||
return self.fs.repo
|
||||
+452
-198
@@ -1,226 +1,480 @@
|
||||
import logging
|
||||
import ntpath
|
||||
import os
|
||||
import typing
|
||||
import posixpath
|
||||
import threading
|
||||
from contextlib import suppress
|
||||
from itertools import takewhile
|
||||
from typing import TYPE_CHECKING, Callable, Optional, Tuple, Type, Union
|
||||
|
||||
from ._callback import DEFAULT_CALLBACK
|
||||
from .base import FileSystem
|
||||
from fsspec.spec import AbstractFileSystem
|
||||
from funcy import cached_property, wrap_prop, wrap_with
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from dvc.types import AnyPath
|
||||
from dvc_objects.fs.base import FileSystem
|
||||
from dvc_objects.fs.callbacks import DEFAULT_CALLBACK
|
||||
from dvc_objects.fs.path import Path
|
||||
|
||||
from .data import DataFileSystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo import Repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RepoFactory = Union[Callable[[str], "Repo"], Type["Repo"]]
|
||||
|
||||
class DvcFileSystem(FileSystem): # pylint:disable=abstract-method
|
||||
"""DVC repo fs.
|
||||
|
||||
def as_posix(path: str) -> str:
|
||||
return path.replace(ntpath.sep, posixpath.sep)
|
||||
|
||||
|
||||
def _wrap_walk(dvc_fs, *args, **kwargs):
|
||||
for root, dnames, fnames in dvc_fs.walk(*args, **kwargs):
|
||||
yield dvc_fs.path.join(dvc_fs.repo.root_dir, root), dnames, fnames
|
||||
|
||||
|
||||
def _ls(fs, path):
|
||||
dnames = []
|
||||
fnames = []
|
||||
|
||||
for entry in fs.ls(path, detail=True):
|
||||
name = fs.path.name(entry["name"])
|
||||
if entry["type"] == "directory":
|
||||
dnames.append(name)
|
||||
else:
|
||||
fnames.append(name)
|
||||
|
||||
return dnames, fnames
|
||||
|
||||
|
||||
def _merge_info(repo, fs_info, dvc_info):
|
||||
from . import utils
|
||||
|
||||
ret = {"repo": repo}
|
||||
|
||||
if dvc_info:
|
||||
ret["dvc_info"] = dvc_info
|
||||
ret["type"] = dvc_info["type"]
|
||||
ret["size"] = dvc_info["size"]
|
||||
if not fs_info and "md5" in dvc_info:
|
||||
ret["md5"] = dvc_info["md5"]
|
||||
|
||||
if fs_info:
|
||||
ret["type"] = fs_info["type"]
|
||||
ret["size"] = fs_info["size"]
|
||||
isexec = False
|
||||
if fs_info["type"] == "file":
|
||||
isexec = utils.is_exec(fs_info["mode"])
|
||||
ret["isexec"] = isexec
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
|
||||
"""DVC + git-tracked files fs.
|
||||
|
||||
Args:
|
||||
repo: DVC repo.
|
||||
repo: DVC or git repo.
|
||||
subrepos: traverse to subrepos (by default, it ignores subrepos)
|
||||
repo_factory: A function to initialize subrepo with, default is Repo.
|
||||
kwargs: Additional keyword arguments passed to the `DataFileSystem()`.
|
||||
"""
|
||||
|
||||
sep = os.sep
|
||||
root_marker = "/"
|
||||
|
||||
scheme = "local"
|
||||
PARAM_CHECKSUM = "md5"
|
||||
PARAM_REPO_URL = "repo_url"
|
||||
PARAM_REPO_ROOT = "repo_root"
|
||||
PARAM_REV = "rev"
|
||||
PARAM_CACHE_DIR = "cache_dir"
|
||||
PARAM_CACHE_TYPES = "cache_types"
|
||||
PARAM_SUBREPOS = "subrepos"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.repo = kwargs["repo"]
|
||||
def __init__(
|
||||
self,
|
||||
repo: Optional["Repo"] = None,
|
||||
subrepos=False,
|
||||
repo_factory: RepoFactory = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
from pygtrie import Trie
|
||||
|
||||
if repo is None:
|
||||
repo, repo_factory = self._repo_from_fs_config(
|
||||
subrepos=subrepos, **kwargs
|
||||
)
|
||||
|
||||
if not repo_factory:
|
||||
from dvc.repo import Repo
|
||||
|
||||
self.repo_factory: RepoFactory = Repo
|
||||
else:
|
||||
self.repo_factory = repo_factory
|
||||
|
||||
def _getcwd():
|
||||
relparts = ()
|
||||
if repo.fs.path.isin(repo.fs.path.getcwd(), repo.root_dir):
|
||||
relparts = repo.fs.path.relparts(
|
||||
repo.fs.path.getcwd(), repo.root_dir
|
||||
)
|
||||
return self.root_marker + self.sep.join(relparts)
|
||||
|
||||
self.path = Path(self.sep, getcwd=_getcwd)
|
||||
self.repo = repo
|
||||
self.hash_jobs = repo.fs.hash_jobs
|
||||
self._traverse_subrepos = subrepos
|
||||
|
||||
self._subrepos_trie = Trie()
|
||||
"""Keeps track of each and every path with the corresponding repo."""
|
||||
|
||||
key = self._get_key(self.repo.root_dir)
|
||||
self._subrepos_trie[key] = repo
|
||||
|
||||
self._datafss = {}
|
||||
"""Keep a datafs instance of each repo."""
|
||||
|
||||
if hasattr(repo, "dvc_dir"):
|
||||
self._datafss[key] = DataFileSystem(repo=repo)
|
||||
|
||||
def _get_key(self, path):
|
||||
parts = self.repo.fs.path.relparts(path, self.repo.root_dir)
|
||||
if parts == (".",):
|
||||
parts = ()
|
||||
return parts
|
||||
|
||||
@property
|
||||
def repo_url(self):
|
||||
if self.repo is None:
|
||||
return None
|
||||
return self.repo.url
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _get_key(self, path):
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
|
||||
from . import get_cloud_fs
|
||||
|
||||
cls, kwargs, fs_path = get_cloud_fs(None, url=path)
|
||||
|
||||
if cls != LocalFileSystem or os.path.isabs(path):
|
||||
fs = cls(**kwargs)
|
||||
return (cls.scheme, *fs.path.parts(fs_path))
|
||||
|
||||
fs_key = "repo"
|
||||
key = self.path.parts(path)
|
||||
if key == (".",):
|
||||
key = ()
|
||||
|
||||
return (fs_key, *key)
|
||||
|
||||
def _get_fs_path(self, path: "AnyPath", remote=None):
|
||||
from dvc.config import NoRemoteError
|
||||
|
||||
info = self.info(path)
|
||||
if info["type"] == "directory":
|
||||
raise IsADirectoryError
|
||||
|
||||
value = info.get("md5")
|
||||
if not value:
|
||||
raise FileNotFoundError
|
||||
|
||||
cache_path = self.repo.odb.local.hash_to_path(value)
|
||||
|
||||
if self.repo.odb.local.fs.exists(cache_path):
|
||||
return self.repo.odb.local.fs, cache_path
|
||||
|
||||
try:
|
||||
remote_odb = self.repo.cloud.get_remote_odb(remote)
|
||||
except NoRemoteError as exc:
|
||||
raise FileNotFoundError from exc
|
||||
remote_fs_path = remote_odb.hash_to_path(value)
|
||||
return remote_odb.fs, remote_fs_path
|
||||
|
||||
def open( # type: ignore
|
||||
self, path: str, mode="r", encoding=None, **kwargs
|
||||
): # pylint: disable=arguments-renamed
|
||||
fs, fspath = self._get_fs_path(path, **kwargs)
|
||||
return fs.open(fspath, mode=mode, encoding=encoding)
|
||||
|
||||
def exists(self, path): # pylint: disable=arguments-renamed
|
||||
try:
|
||||
self.info(path)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def isdir(self, path): # pylint: disable=arguments-renamed
|
||||
try:
|
||||
return self.info(path)["type"] == "directory"
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def isfile(self, path): # pylint: disable=arguments-renamed
|
||||
try:
|
||||
return self.info(path)["type"] == "file"
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def _walk(self, root, topdown=True, **kwargs):
|
||||
dirs = set()
|
||||
files = []
|
||||
|
||||
root_parts = self._get_key(root)
|
||||
root_len = len(root_parts)
|
||||
try:
|
||||
for key, (meta, hash_info) in self.repo.index.tree.iteritems(
|
||||
prefix=root_parts
|
||||
): # noqa: B301
|
||||
if hash_info and hash_info.isdir and meta and not meta.obj:
|
||||
raise FileNotFoundError
|
||||
|
||||
if key == root_parts:
|
||||
continue
|
||||
|
||||
if hash_info.isdir:
|
||||
continue
|
||||
|
||||
name = key[root_len]
|
||||
if len(key) > root_len + 1:
|
||||
dirs.add(name)
|
||||
continue
|
||||
|
||||
files.append(name)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
assert topdown
|
||||
dirs = list(dirs)
|
||||
yield root, dirs, files
|
||||
|
||||
for dname in dirs:
|
||||
yield from self._walk(self.path.join(root, dname))
|
||||
|
||||
def walk(self, top, topdown=True, onerror=None, **kwargs):
|
||||
assert topdown
|
||||
try:
|
||||
info = self.info(top)
|
||||
except FileNotFoundError:
|
||||
if onerror is not None:
|
||||
onerror(FileNotFoundError(top))
|
||||
return
|
||||
|
||||
if info["type"] != "directory":
|
||||
if onerror is not None:
|
||||
onerror(NotADirectoryError(top))
|
||||
return
|
||||
|
||||
yield from self._walk(top, topdown=topdown, **kwargs)
|
||||
|
||||
def find(self, path, prefix=None):
|
||||
for root, _, files in self.walk(path):
|
||||
for fname in files:
|
||||
# NOTE: os.path.join is ~5.5 times slower
|
||||
yield f"{root}{os.sep}{fname}"
|
||||
|
||||
def isdvc(self, path, recursive=False, strict=True):
|
||||
try:
|
||||
info = self.info(path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
recurse = recursive or not strict
|
||||
return bool(info.get("outs") if recurse else info.get("isout"))
|
||||
|
||||
def info(self, path):
|
||||
from dvc.data.meta import Meta
|
||||
|
||||
key = self._get_key(path)
|
||||
|
||||
try:
|
||||
outs = list(self.repo.index.tree.iteritems(key))
|
||||
except KeyError as exc:
|
||||
raise FileNotFoundError from exc
|
||||
|
||||
ret = {
|
||||
"type": "file",
|
||||
"size": 0,
|
||||
"isexec": False,
|
||||
"isdvc": False,
|
||||
"outs": outs,
|
||||
return {
|
||||
self.PARAM_REPO_URL: self.repo_url,
|
||||
self.PARAM_REPO_ROOT: self.repo.root_dir,
|
||||
self.PARAM_REV: getattr(self.repo.fs, "rev", None),
|
||||
self.PARAM_CACHE_DIR: os.path.abspath(
|
||||
self.repo.odb.local.cache_dir
|
||||
),
|
||||
self.PARAM_CACHE_TYPES: self.repo.odb.local.cache_types,
|
||||
self.PARAM_SUBREPOS: self._traverse_subrepos,
|
||||
}
|
||||
|
||||
if len(outs) > 1 and outs[0][0] != key:
|
||||
shortest = self.repo.index.tree.shortest_prefix(key)
|
||||
if shortest:
|
||||
assert shortest[1][1].isdir
|
||||
if len(shortest[0]) <= len(key):
|
||||
ret["isdvc"] = True
|
||||
@classmethod
|
||||
def _repo_from_fs_config(
|
||||
cls, **config
|
||||
) -> Tuple["Repo", Optional["RepoFactory"]]:
|
||||
from dvc.external_repo import erepo_factory, external_repo
|
||||
from dvc.repo import Repo
|
||||
|
||||
ret["type"] = "directory"
|
||||
return ret
|
||||
url = config.get(cls.PARAM_REPO_URL)
|
||||
root = config.get(cls.PARAM_REPO_ROOT)
|
||||
assert url or root
|
||||
|
||||
item_key, (meta, hash_info) = outs[0]
|
||||
def _open(*args, **kwargs):
|
||||
# NOTE: if original repo was an erepo (and has a URL),
|
||||
# we cannot use Repo.open() since it will skip erepo
|
||||
# cache/remote setup for local URLs
|
||||
if url is None:
|
||||
return Repo.open(*args, **kwargs)
|
||||
return external_repo(*args, **kwargs)
|
||||
|
||||
meta = meta or Meta()
|
||||
cache_dir = config.get(cls.PARAM_CACHE_DIR)
|
||||
cache_config = (
|
||||
{}
|
||||
if not cache_dir
|
||||
else {
|
||||
"cache": {
|
||||
"dir": cache_dir,
|
||||
"type": config.get(cls.PARAM_CACHE_TYPES),
|
||||
}
|
||||
}
|
||||
)
|
||||
repo_kwargs: dict = {
|
||||
"rev": config.get(cls.PARAM_REV),
|
||||
"subrepos": config.get(cls.PARAM_SUBREPOS, False),
|
||||
"uninitialized": True,
|
||||
}
|
||||
factory: Optional["RepoFactory"] = None
|
||||
if url is None:
|
||||
repo_kwargs["config"] = cache_config
|
||||
else:
|
||||
repo_kwargs["cache_dir"] = cache_dir
|
||||
factory = erepo_factory(url, root, cache_config)
|
||||
|
||||
if key != item_key:
|
||||
assert item_key[: len(key)] == key
|
||||
ret["type"] = "directory"
|
||||
return ret
|
||||
with _open(
|
||||
url if url else root,
|
||||
**repo_kwargs,
|
||||
) as repo:
|
||||
return repo, factory
|
||||
|
||||
ret["size"] = meta.size
|
||||
ret["isexec"] = meta.isexec
|
||||
ret[hash_info.name] = hash_info.value
|
||||
ret["isdvc"] = True
|
||||
ret["isout"] = True
|
||||
ret["meta"] = meta
|
||||
if hash_info and hash_info.isdir:
|
||||
ret["type"] = "directory"
|
||||
return ret
|
||||
def _get_repo(self, path: str) -> "Repo":
|
||||
"""Returns repo that the path falls in, using prefix.
|
||||
|
||||
def get_file(
|
||||
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
fs, path = self._get_fs_path(from_info)
|
||||
fs.get_file( # pylint: disable=protected-access
|
||||
path, to_file, callback=callback, **kwargs
|
||||
If the path is already tracked/collected, it just returns the repo.
|
||||
|
||||
Otherwise, it collects the repos that might be in the path's parents
|
||||
and then returns the appropriate one.
|
||||
"""
|
||||
if not self.repo.fs.path.isin_or_eq(path, self.repo.root_dir):
|
||||
# outside of repo
|
||||
return self.repo
|
||||
|
||||
key = self._get_key(path)
|
||||
repo = self._subrepos_trie.get(key)
|
||||
if repo:
|
||||
return repo
|
||||
|
||||
prefix_key, repo = self._subrepos_trie.longest_prefix(key)
|
||||
prefix = self.repo.fs.path.join(
|
||||
self.repo.root_dir,
|
||||
*prefix_key, # pylint: disable=not-an-iterable
|
||||
)
|
||||
|
||||
parents = (parent for parent in self.repo.fs.path.parents(path))
|
||||
dirs = [path] + list(takewhile(lambda p: p != prefix, parents))
|
||||
dirs.reverse()
|
||||
self._update(dirs, starting_repo=repo)
|
||||
return self._subrepos_trie.get(key) or self.repo
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
def _update(self, dirs, starting_repo):
|
||||
"""Checks for subrepo in directories and updates them."""
|
||||
repo = starting_repo
|
||||
for d in dirs:
|
||||
key = self._get_key(d)
|
||||
if self._is_dvc_repo(d):
|
||||
repo = self.repo_factory(
|
||||
d,
|
||||
fs=self.repo.fs,
|
||||
scm=self.repo.scm,
|
||||
repo_factory=self.repo_factory,
|
||||
)
|
||||
self._datafss[key] = DataFileSystem(repo=repo)
|
||||
self._subrepos_trie[key] = repo
|
||||
|
||||
def _is_dvc_repo(self, dir_path):
|
||||
"""Check if the directory is a dvc repo."""
|
||||
if not self._traverse_subrepos:
|
||||
return False
|
||||
|
||||
from dvc.repo import Repo
|
||||
|
||||
repo_path = self.repo.fs.path.join(dir_path, Repo.DVC_DIR)
|
||||
return self.repo.fs.isdir(repo_path)
|
||||
|
||||
def _get_fs_pair(
|
||||
self, path
|
||||
) -> Tuple[
|
||||
Optional[FileSystem],
|
||||
Optional[str],
|
||||
Optional[DataFileSystem],
|
||||
Optional[str],
|
||||
]:
|
||||
"""
|
||||
Returns a pair of fss based on repo the path falls in, using prefix.
|
||||
"""
|
||||
parts = self.path.relparts(path, self.root_marker)
|
||||
if parts and parts[0] == os.curdir:
|
||||
parts = parts[1:]
|
||||
|
||||
fs_path = self.repo.fs.path.join(self.repo.root_dir, *parts)
|
||||
repo = self._get_repo(fs_path)
|
||||
fs = repo.fs
|
||||
|
||||
repo_parts = fs.path.relparts(repo.root_dir, self.repo.root_dir)
|
||||
if repo_parts[0] == os.curdir:
|
||||
repo_parts = repo_parts[1:]
|
||||
|
||||
dvc_parts = parts[len(repo_parts) :]
|
||||
if dvc_parts and dvc_parts[0] == os.curdir:
|
||||
dvc_parts = dvc_parts[1:]
|
||||
|
||||
key = self._get_key(repo.root_dir)
|
||||
dvc_fs = self._datafss.get(key)
|
||||
if dvc_fs:
|
||||
dvc_path = dvc_fs.path.join(*dvc_parts) if dvc_parts else ""
|
||||
else:
|
||||
dvc_path = None
|
||||
|
||||
return fs, fs_path, dvc_fs, dvc_path
|
||||
|
||||
def open(
|
||||
self, path, mode="r", encoding="utf-8", **kwargs
|
||||
): # pylint: disable=arguments-renamed, arguments-differ
|
||||
if "b" in mode:
|
||||
encoding = None
|
||||
|
||||
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
try:
|
||||
return fs.open(fs_path, mode=mode, encoding=encoding)
|
||||
except FileNotFoundError:
|
||||
if not dvc_fs:
|
||||
raise
|
||||
|
||||
return dvc_fs.open(dvc_path, mode=mode, encoding=encoding, **kwargs)
|
||||
|
||||
def isdvc(self, path, **kwargs):
|
||||
_, _, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
return dvc_fs is not None and dvc_fs.isdvc(dvc_path, **kwargs)
|
||||
|
||||
def ls( # pylint: disable=arguments-differ
|
||||
self, path, detail=True, dvc_only=False, **kwargs
|
||||
):
|
||||
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
repo = dvc_fs.repo if dvc_fs else self.repo
|
||||
dvcignore = repo.dvcignore
|
||||
ignore_subrepos = kwargs.get("ignore_subrepos", True)
|
||||
|
||||
names = set()
|
||||
if dvc_fs:
|
||||
with suppress(FileNotFoundError):
|
||||
for entry in dvc_fs.ls(dvc_path, detail=False):
|
||||
names.add(dvc_fs.path.name(entry))
|
||||
|
||||
if not dvc_only and fs:
|
||||
try:
|
||||
for entry in dvcignore.ls(
|
||||
fs, fs_path, detail=False, ignore_subrepos=ignore_subrepos
|
||||
):
|
||||
names.add(fs.path.name(entry))
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
pass
|
||||
|
||||
dvcfiles = kwargs.get("dvcfiles", False)
|
||||
|
||||
def _func(fname):
|
||||
from dvc.dvcfile import is_valid_filename
|
||||
from dvc.ignore import DvcIgnore
|
||||
|
||||
if dvcfiles:
|
||||
return True
|
||||
|
||||
return not (
|
||||
is_valid_filename(fname) or fname == DvcIgnore.DVCIGNORE_FILE
|
||||
)
|
||||
|
||||
names = filter(_func, names)
|
||||
|
||||
infos = []
|
||||
paths = []
|
||||
for name in names:
|
||||
entry_path = self.path.join(path, name)
|
||||
try:
|
||||
info = self.info(entry_path, ignore_subrepos=ignore_subrepos)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
infos.append(info)
|
||||
paths.append(entry_path)
|
||||
|
||||
if not detail:
|
||||
return paths
|
||||
|
||||
return infos
|
||||
|
||||
def get_file( # pylint: disable=arguments-differ
|
||||
self, rpath, lpath, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(rpath)
|
||||
|
||||
if fs:
|
||||
try:
|
||||
fs.get_file(fs_path, lpath, callback=callback, **kwargs)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
if not dvc_fs:
|
||||
raise
|
||||
dvc_fs.get_file(dvc_path, lpath, callback=callback, **kwargs)
|
||||
|
||||
def info(self, path, **kwargs):
|
||||
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
repo = dvc_fs.repo if dvc_fs else self.repo
|
||||
dvcignore = repo.dvcignore
|
||||
ignore_subrepos = kwargs.get("ignore_subrepos", True)
|
||||
|
||||
dvc_info = None
|
||||
if dvc_fs:
|
||||
try:
|
||||
dvc_info = dvc_fs.info(dvc_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
fs_info = None
|
||||
if fs:
|
||||
try:
|
||||
fs_info = fs.info(fs_path)
|
||||
if dvcignore.is_ignored(
|
||||
fs, fs_path, ignore_subrepos=ignore_subrepos
|
||||
):
|
||||
fs_info = None
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
if not dvc_info:
|
||||
raise
|
||||
|
||||
# NOTE: if some parent in fs_path turns out to be a file, it means
|
||||
# that the whole repofs branch doesn't exist.
|
||||
if fs and not fs_info and dvc_info:
|
||||
for parent in fs.path.parents(fs_path):
|
||||
try:
|
||||
if fs.info(parent)["type"] != "directory":
|
||||
dvc_info = None
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
if not dvc_info and not fs_info:
|
||||
raise FileNotFoundError
|
||||
|
||||
info = _merge_info(repo, fs_info, dvc_info)
|
||||
info["name"] = path
|
||||
return info
|
||||
|
||||
def checksum(self, path):
|
||||
info = self.info(path)
|
||||
md5 = info.get("md5")
|
||||
if md5:
|
||||
return md5
|
||||
raise NotImplementedError
|
||||
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
try:
|
||||
return fs.checksum(fs_path)
|
||||
except FileNotFoundError:
|
||||
return dvc_fs.checksum(dvc_path)
|
||||
|
||||
|
||||
class DvcFileSystem(FileSystem):
|
||||
protocol = "local"
|
||||
PARAM_CHECKSUM = "md5"
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
return config
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
return _DvcFileSystem(**self.fs_args)
|
||||
|
||||
def isdvc(self, path, **kwargs):
|
||||
return self.fs.isdvc(path, **kwargs)
|
||||
|
||||
@property
|
||||
def path(self): # pylint: disable=invalid-overridden-method
|
||||
return self.fs.path
|
||||
|
||||
@property
|
||||
def repo(self):
|
||||
return self.fs.repo
|
||||
|
||||
@property
|
||||
def repo_url(self):
|
||||
return self.fs.repo_url
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return self.fs.config
|
||||
|
||||
def from_os_path(self, path):
|
||||
if os.path.isabs(path):
|
||||
path = os.path.relpath(path, self.repo.root_dir)
|
||||
|
||||
return as_posix(path)
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
from typing import IO, TYPE_CHECKING, Any, Dict, Iterator, Optional, overload
|
||||
|
||||
from funcy import cached_property
|
||||
from tqdm.utils import CallbackIOWrapper
|
||||
|
||||
from ._callback import DEFAULT_CALLBACK
|
||||
from .base import FileSystem
|
||||
|
||||
FSPath = str
|
||||
AnyFSPath = str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import Literal
|
||||
|
||||
|
||||
# An info() entry, might evolve to a TypedDict
|
||||
# in the future (e.g for properly type 'size' etc).
|
||||
Entry = Dict[str, Any]
|
||||
|
||||
|
||||
# pylint: disable=no-member
|
||||
class FSSpecWrapper(FileSystem):
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.fs_args = {"skip_instance_cache": True}
|
||||
self.fs_args.update(self._prepare_credentials(**kwargs))
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath: str) -> "Dict[str, Any]":
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
options = infer_storage_options(urlpath)
|
||||
options.pop("path", None)
|
||||
options.pop("protocol", None)
|
||||
return options
|
||||
|
||||
@cached_property
|
||||
def fs(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _prepare_credentials(
|
||||
self, **config: Dict[str, Any] # pylint: disable=unused-argument
|
||||
) -> Dict[str, Any]:
|
||||
"""Prepare the arguments for authentication to the
|
||||
host filesystem"""
|
||||
return {}
|
||||
|
||||
def _isdir(self, path: AnyFSPath) -> bool:
|
||||
return self.fs.isdir(path)
|
||||
|
||||
def isdir(self, path: AnyFSPath) -> bool:
|
||||
try:
|
||||
return self._isdir(path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def isfile(self, path: AnyFSPath) -> bool:
|
||||
try:
|
||||
return not self._isdir(path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def is_empty(self, path: AnyFSPath) -> bool:
|
||||
entry = self.info(path)
|
||||
if entry["type"] == "directory":
|
||||
return not self.fs.ls(path)
|
||||
return entry["size"] == 0
|
||||
|
||||
def open(
|
||||
self,
|
||||
path: AnyFSPath,
|
||||
mode: str = "r",
|
||||
encoding: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> "IO": # pylint: disable=arguments-differ
|
||||
return self.fs.open(path, mode=mode)
|
||||
|
||||
def checksum(self, path: AnyFSPath) -> str:
|
||||
return self.fs.checksum(path)
|
||||
|
||||
def copy(self, from_info: AnyFSPath, to_info: AnyFSPath) -> None:
|
||||
self.makedirs(self.path.parent(to_info))
|
||||
self.fs.copy(from_info, to_info)
|
||||
|
||||
def exists(self, path: AnyFSPath) -> bool:
|
||||
return self.fs.exists(path)
|
||||
|
||||
@overload
|
||||
def ls(
|
||||
self, path: AnyFSPath, detail: "Literal[True]"
|
||||
) -> "Iterator[Entry]":
|
||||
...
|
||||
|
||||
@overload
|
||||
def ls(self, path: AnyFSPath, detail: "Literal[False]") -> Iterator[str]:
|
||||
...
|
||||
|
||||
def ls(self, path, detail=False):
|
||||
yield from self.fs.ls(path, detail=detail)
|
||||
|
||||
def find(self, path, prefix=None):
|
||||
yield from self.fs.find(path)
|
||||
|
||||
def move(self, from_info: AnyFSPath, to_info: AnyFSPath) -> None:
|
||||
self.fs.move(from_info, to_info)
|
||||
|
||||
def remove(self, path: AnyFSPath) -> None:
|
||||
self.fs.rm_file(path)
|
||||
|
||||
def info(self, path: AnyFSPath) -> "Entry":
|
||||
return self.fs.info(path)
|
||||
|
||||
def makedirs(self, path: AnyFSPath, **kwargs) -> None:
|
||||
self.fs.makedirs(path, exist_ok=kwargs.pop("exist_ok", True))
|
||||
|
||||
def put_file(
|
||||
self,
|
||||
from_file: AnyFSPath,
|
||||
to_info: AnyFSPath,
|
||||
callback: Any = DEFAULT_CALLBACK,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.fs.put_file(from_file, to_info, callback=callback, **kwargs)
|
||||
self.fs.invalidate_cache(self.path.parent(to_info))
|
||||
|
||||
def get_file(
|
||||
self,
|
||||
from_info: AnyFSPath,
|
||||
to_info: AnyFSPath,
|
||||
callback: Any = DEFAULT_CALLBACK,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.fs.get_file(from_info, to_info, callback=callback, **kwargs)
|
||||
|
||||
def upload_fobj(self, fobj: IO, to_info: AnyFSPath, **kwargs) -> None:
|
||||
self.makedirs(self.path.parent(to_info))
|
||||
with self.open(to_info, "wb") as fdest:
|
||||
shutil.copyfileobj(
|
||||
fobj,
|
||||
fdest,
|
||||
length=getattr(fdest, "blocksize", None), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class ObjectFSWrapper(FSSpecWrapper):
|
||||
TRAVERSE_PREFIX_LEN = 3
|
||||
|
||||
def makedirs(self, path: AnyFSPath, **kwargs) -> None:
|
||||
# For object storages make this method a no-op. The original
|
||||
# fs.makedirs() method will only check if the bucket exists
|
||||
# and create if it doesn't though we don't want to support
|
||||
# that behavior, and the check will cost some time so we'll
|
||||
# simply ignore all mkdir()/makedirs() calls.
|
||||
return None
|
||||
|
||||
def _isdir(self, path: AnyFSPath) -> bool:
|
||||
# Directory in object storages are interpreted differently
|
||||
# among different fsspec providers, so this logic is a temporary
|
||||
# measure for us to adapt as of now. It checks whether it is a
|
||||
# directory (as in a prefix with contents) or whether it is an empty
|
||||
# file where it's name ends with a forward slash
|
||||
|
||||
entry = self.info(path)
|
||||
return entry["type"] == "directory" or (
|
||||
entry["size"] == 0
|
||||
and entry["type"] == "file"
|
||||
and entry["name"].endswith("/")
|
||||
)
|
||||
|
||||
def find(self, path, prefix=None):
|
||||
if prefix:
|
||||
with_prefix = self.path.parent(path)
|
||||
files = self.fs.find(with_prefix, prefix=self.path.parts(path)[-1])
|
||||
else:
|
||||
with_prefix = path
|
||||
files = self.fs.find(path)
|
||||
|
||||
# When calling find() on a file, it returns the same file in a list.
|
||||
# For object-based storages, the same behavior applies to empty
|
||||
# directories since they are represented as files. This condition
|
||||
# checks whether we should yield an empty list (if it is an empty
|
||||
# directory) or just yield the file itself.
|
||||
if len(files) == 1 and files[0] == with_prefix and self.isdir(path):
|
||||
return None
|
||||
|
||||
yield from files
|
||||
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
class NoDirectoriesMixin:
|
||||
def isdir(self, *args, **kwargs):
|
||||
return False
|
||||
|
||||
def isfile(self, *args, **kwargs):
|
||||
return True
|
||||
|
||||
def find(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def walk(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def ls(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class CallbackMixin:
|
||||
"""Provides callback support for the filesystem that don't support yet."""
|
||||
|
||||
def put_file(
|
||||
self,
|
||||
from_file,
|
||||
to_info,
|
||||
callback=DEFAULT_CALLBACK,
|
||||
**kwargs,
|
||||
):
|
||||
"""Add compatibility support for Callback."""
|
||||
# pylint: disable=protected-access
|
||||
self.makedirs(self.path.parent(to_info))
|
||||
size = os.path.getsize(from_file)
|
||||
with open(from_file, "rb") as fobj:
|
||||
callback.set_size(size)
|
||||
wrapped = CallbackIOWrapper(callback.relative_update, fobj)
|
||||
self.upload_fobj(wrapped, to_info)
|
||||
self.fs.invalidate_cache(self.path.parent(to_info))
|
||||
|
||||
def get_file(
|
||||
self,
|
||||
from_info,
|
||||
to_info,
|
||||
callback=DEFAULT_CALLBACK,
|
||||
**kwargs,
|
||||
):
|
||||
# pylint: disable=protected-access
|
||||
total: int = self.getsize(from_info)
|
||||
if total:
|
||||
callback.set_size(total)
|
||||
|
||||
with self.open(from_info, "rb") as fobj, open(to_info, "wb") as fdest:
|
||||
wrapped = CallbackIOWrapper(callback.relative_update, fobj)
|
||||
shutil.copyfileobj(wrapped, fdest, length=fobj.blocksize)
|
||||
@@ -1,259 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils import format_link, tmp_fname
|
||||
|
||||
from .fsspec_wrapper import FSSpecWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"
|
||||
|
||||
|
||||
class GDriveAuthError(DvcException):
|
||||
def __init__(self, cred_location):
|
||||
|
||||
if cred_location:
|
||||
message = (
|
||||
"GDrive remote auth failed with credentials in '{}'.\n"
|
||||
"Backup first, remove or fix them, and run DVC again.\n"
|
||||
"It should do auth again and refresh the credentials.\n\n"
|
||||
"Details:".format(cred_location)
|
||||
)
|
||||
else:
|
||||
message = "Failed to authenticate GDrive remote"
|
||||
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class GDriveFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
|
||||
scheme = Schemes.GDRIVE
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
REQUIRES = {"pydrive2": "pydrive2"}
|
||||
# Always prefer traverse for GDrive since API usage quotas are a concern.
|
||||
TRAVERSE_WEIGHT_MULTIPLIER = 1
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
|
||||
GDRIVE_CREDENTIALS_DATA = "GDRIVE_CREDENTIALS_DATA"
|
||||
DEFAULT_USER_CREDENTIALS_FILE = "gdrive-user-credentials.json"
|
||||
|
||||
DEFAULT_GDRIVE_CLIENT_ID = "710796635688-iivsgbgsb6uv1fap6635dhvuei09o66c.apps.googleusercontent.com" # noqa: E501
|
||||
DEFAULT_GDRIVE_CLIENT_SECRET = "a1Fz59uTpVNeG_VGuSKDLJXv"
|
||||
|
||||
def __init__(self, **config):
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
super().__init__(**config)
|
||||
|
||||
self.url = config["url"]
|
||||
opts = infer_storage_options(self.url)
|
||||
|
||||
if not opts["host"]:
|
||||
raise DvcException(
|
||||
"Empty GDrive URL '{}'. Learn more at {}".format(
|
||||
config["url"],
|
||||
format_link("https://man.dvc.org/remote/add"),
|
||||
)
|
||||
)
|
||||
|
||||
self._bucket = opts["host"]
|
||||
self._path = opts["path"].lstrip("/")
|
||||
self._trash_only = config.get("gdrive_trash_only")
|
||||
self._use_service_account = config.get("gdrive_use_service_account")
|
||||
self._service_account_user_email = config.get(
|
||||
"gdrive_service_account_user_email"
|
||||
)
|
||||
self._service_account_json_file_path = config.get(
|
||||
"gdrive_service_account_json_file_path"
|
||||
)
|
||||
self._client_id = config.get("gdrive_client_id")
|
||||
self._client_secret = config.get("gdrive_client_secret")
|
||||
self._validate_config()
|
||||
|
||||
tmp_dir = config["gdrive_credentials_tmp_dir"]
|
||||
assert tmp_dir
|
||||
|
||||
self._gdrive_service_credentials_path = tmp_fname(
|
||||
os.path.join(tmp_dir, "")
|
||||
)
|
||||
self._gdrive_user_credentials_path = (
|
||||
tmp_fname(os.path.join(tmp_dir, ""))
|
||||
if os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA)
|
||||
else config.get(
|
||||
"gdrive_user_credentials_file",
|
||||
os.path.join(tmp_dir, self.DEFAULT_USER_CREDENTIALS_FILE),
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path):
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
opts = infer_storage_options(path)
|
||||
|
||||
return "{host}{path}".format(**opts)
|
||||
|
||||
def unstrip_protocol(self, path):
|
||||
return f"gdrive://{path}"
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
return {"url": urlpath}
|
||||
|
||||
def _validate_config(self):
|
||||
# Validate Service Account configuration
|
||||
if (
|
||||
self._use_service_account
|
||||
and not self._service_account_json_file_path
|
||||
):
|
||||
raise DvcException(
|
||||
"To use service account, set "
|
||||
"`gdrive_service_account_json_file_path`, and optionally"
|
||||
"`gdrive_service_account_user_email` in DVC config\n"
|
||||
"Learn more at {}".format(
|
||||
format_link("https://man.dvc.org/remote/modify")
|
||||
)
|
||||
)
|
||||
|
||||
# Validate OAuth 2.0 Client ID configuration
|
||||
if not self._use_service_account:
|
||||
if bool(self._client_id) != bool(self._client_secret):
|
||||
raise DvcException(
|
||||
"Please specify GDrive's client ID and secret in "
|
||||
"DVC config or omit both to use the defaults.\n"
|
||||
"Learn more at {}".format(
|
||||
format_link("https://man.dvc.org/remote/modify")
|
||||
)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def credentials_location(self):
|
||||
"""
|
||||
Helper to determine where will GDrive remote read credentials from.
|
||||
Useful for tests, exception messages, etc. Returns either env variable
|
||||
name if it's set or actual path to the credentials file.
|
||||
"""
|
||||
if os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA):
|
||||
return GDriveFileSystem.GDRIVE_CREDENTIALS_DATA
|
||||
if os.path.exists(self._gdrive_user_credentials_path):
|
||||
return self._gdrive_user_credentials_path
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _validate_credentials(auth, settings):
|
||||
"""
|
||||
Detects discrepancy in DVC config and cached credentials file.
|
||||
Usually happens when a second remote is added and it is using
|
||||
the same credentials default file. Or when someones decides to change
|
||||
DVC config client id or secret but forgets to remove the cached
|
||||
credentials file.
|
||||
"""
|
||||
if not os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA):
|
||||
if (
|
||||
settings["client_config"]["client_id"]
|
||||
!= auth.credentials.client_id
|
||||
or settings["client_config"]["client_secret"]
|
||||
!= auth.credentials.client_secret
|
||||
):
|
||||
logger.warning(
|
||||
"Client ID and secret configured do not match the "
|
||||
"actual ones used\nto access the remote. Do you "
|
||||
"use multiple GDrive remotes and forgot to\nset "
|
||||
"`gdrive_user_credentials_file` for one or more of them? "
|
||||
"Learn more at\n{}.\n".format(
|
||||
format_link("https://man.dvc.org/remote/modify")
|
||||
)
|
||||
)
|
||||
|
||||
@wrap_prop(threading.RLock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from pydrive2.auth import GoogleAuth
|
||||
from pydrive2.fs import GDriveFileSystem as _GDriveFileSystem
|
||||
|
||||
temporary_save_path = self._gdrive_user_credentials_path
|
||||
is_credentials_temp = os.getenv(
|
||||
GDriveFileSystem.GDRIVE_CREDENTIALS_DATA
|
||||
)
|
||||
if self._use_service_account:
|
||||
temporary_save_path = self._gdrive_service_credentials_path
|
||||
|
||||
if is_credentials_temp:
|
||||
with open(temporary_save_path, "w", encoding="utf-8") as cred_file:
|
||||
cred_file.write(
|
||||
os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA)
|
||||
)
|
||||
|
||||
auth_settings = {
|
||||
"client_config_backend": "settings",
|
||||
"save_credentials": True,
|
||||
"save_credentials_backend": "file",
|
||||
"save_credentials_file": self._gdrive_user_credentials_path,
|
||||
"get_refresh_token": True,
|
||||
"oauth_scope": [
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
"https://www.googleapis.com/auth/drive.appdata",
|
||||
],
|
||||
}
|
||||
|
||||
if self._use_service_account:
|
||||
auth_settings["service_config"] = {
|
||||
"client_user_email": self._service_account_user_email,
|
||||
"client_json_file_path": self._service_account_json_file_path,
|
||||
}
|
||||
if is_credentials_temp:
|
||||
auth_settings["service_config"][
|
||||
"client_json_file_path"
|
||||
] = temporary_save_path
|
||||
|
||||
else:
|
||||
auth_settings["client_config"] = {
|
||||
"client_id": self._client_id or self.DEFAULT_GDRIVE_CLIENT_ID,
|
||||
"client_secret": self._client_secret
|
||||
or self.DEFAULT_GDRIVE_CLIENT_SECRET,
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"revoke_uri": "https://oauth2.googleapis.com/revoke",
|
||||
"redirect_uri": "",
|
||||
}
|
||||
|
||||
GoogleAuth.DEFAULT_SETTINGS.update(auth_settings)
|
||||
|
||||
# Pass non existent settings path to force DEFAULT_SETTINGS loadings
|
||||
gauth = GoogleAuth(settings_file="")
|
||||
|
||||
try:
|
||||
logger.debug(
|
||||
"GDrive remote auth with config '{}'.".format(
|
||||
GoogleAuth.DEFAULT_SETTINGS
|
||||
)
|
||||
)
|
||||
if self._use_service_account:
|
||||
gauth.ServiceAuth()
|
||||
else:
|
||||
gauth.CommandLineAuth()
|
||||
GDriveFileSystem._validate_credentials(gauth, auth_settings)
|
||||
|
||||
# Handle AuthenticationError, RefreshError and other auth failures
|
||||
# It's hard to come up with a narrow exception, since PyDrive throws
|
||||
# a lot of different errors - broken credentials file, refresh token
|
||||
# expired, flow failed, etc.
|
||||
except Exception as exc:
|
||||
raise GDriveAuthError(self.credentials_location) from exc
|
||||
finally:
|
||||
if is_credentials_temp:
|
||||
os.remove(temporary_save_path)
|
||||
|
||||
return _GDriveFileSystem(
|
||||
f"{self._bucket}/{self._path}",
|
||||
gauth,
|
||||
trash_only=self._trash_only,
|
||||
)
|
||||
|
||||
def upload_fobj(self, fobj, to_info, **kwargs):
|
||||
self.makedirs(os.path.dirname(to_info))
|
||||
return self.fs.upload_fobj(fobj, to_info, **kwargs)
|
||||
+10
-17
@@ -1,10 +1,9 @@
|
||||
import os
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from .fsspec_wrapper import AnyFSPath, FSSpecWrapper
|
||||
from . import FileSystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from scmrepo.fs import GitFileSystem as FsspecGitFileSystem
|
||||
@@ -12,11 +11,10 @@ if TYPE_CHECKING:
|
||||
from scmrepo.git.objects import GitTrie
|
||||
|
||||
|
||||
class GitFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
|
||||
class GitFileSystem(FileSystem): # pylint:disable=abstract-method
|
||||
"""Proxies the repo file access methods to Git objects"""
|
||||
|
||||
sep = os.sep
|
||||
scheme = "local"
|
||||
protocol = "local"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -47,18 +45,13 @@ class GitFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
|
||||
|
||||
return FsspecGitFileSystem(**self.fs_args)
|
||||
|
||||
@cached_property
|
||||
def path(self):
|
||||
return self.fs.path
|
||||
|
||||
@property
|
||||
def rev(self) -> str:
|
||||
return self.fs.rev
|
||||
|
||||
def isfile(self, path: AnyFSPath) -> bool:
|
||||
return self.fs.isfile(path)
|
||||
|
||||
def walk(
|
||||
self,
|
||||
top: AnyFSPath,
|
||||
topdown: bool = True,
|
||||
onerror: Callable[[OSError], None] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
return self.fs.walk(top, topdown=topdown, onerror=onerror, **kwargs)
|
||||
def ls(self, path, detail=True, **kwargs):
|
||||
return self.fs.ls(path, detail=detail, **kwargs) or []
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
from .fsspec_wrapper import CallbackMixin, ObjectFSWrapper
|
||||
|
||||
|
||||
class GSFileSystem(CallbackMixin, ObjectFSWrapper):
|
||||
scheme = Schemes.GS
|
||||
REQUIRES = {"gcsfs": "gcsfs"}
|
||||
PARAM_CHECKSUM = "etag"
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
login_info = {"consistency": None}
|
||||
login_info["project"] = config.get("projectname")
|
||||
login_info["token"] = config.get("credentialpath")
|
||||
return login_info
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from gcsfs import GCSFileSystem
|
||||
|
||||
return GCSFileSystem(**self.fs_args)
|
||||
@@ -1,99 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils import fix_env
|
||||
|
||||
from .fsspec_wrapper import CallbackMixin, FSSpecWrapper
|
||||
|
||||
CHECKSUM_REGEX = re.compile(r".*\t.*\t(?P<checksum>.*)")
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class HDFSFileSystem(CallbackMixin, FSSpecWrapper):
|
||||
scheme = Schemes.HDFS
|
||||
REQUIRES = {"fsspec": "fsspec", "pyarrow": "pyarrow"}
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str) -> str:
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
return infer_storage_options(path)["path"].lstrip("/")
|
||||
|
||||
def unstrip_protocol(self, path: str) -> str:
|
||||
host = self.fs_args["host"]
|
||||
port = self.fs_args.get("port")
|
||||
netloc = host + (f":{port}" if port else "")
|
||||
return "hdfs://" + netloc + "/" + path.lstrip("/")
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
from fsspec.implementations.arrow import HadoopFileSystem
|
||||
|
||||
# pylint:disable=protected-access
|
||||
return HadoopFileSystem._get_kwargs_from_urls(urlpath)
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
return config
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from fsspec.implementations.arrow import HadoopFileSystem
|
||||
|
||||
return HadoopFileSystem(**self.fs_args)
|
||||
|
||||
def checksum(self, path):
|
||||
return self._checksum(path)
|
||||
|
||||
def _checksum(self, path, **kwargs):
|
||||
# PyArrow doesn't natively support retrieving the
|
||||
# checksum, so we have to use hadoop fs
|
||||
|
||||
url = self.unstrip_protocol(path)
|
||||
|
||||
result = self._run_command(
|
||||
f"checksum {url}",
|
||||
env=fix_env(os.environ),
|
||||
user=self.fs_args["user"],
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
match = CHECKSUM_REGEX.match(result)
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
return match.group("checksum")
|
||||
|
||||
def _run_command(self, cmd, env=None, user=None):
|
||||
cmd = "hadoop fs -" + cmd
|
||||
if user:
|
||||
cmd = f"HADOOP_USER_NAME={user} " + cmd
|
||||
|
||||
# NOTE: close_fds doesn't work with redirected stdin/stdout/stderr.
|
||||
# See https://github.com/iterative/dvc/issues/1197.
|
||||
close_fds = os.name != "nt"
|
||||
|
||||
executable = os.getenv("SHELL") if os.name != "nt" else None
|
||||
p = subprocess.Popen(
|
||||
cmd,
|
||||
shell=True,
|
||||
close_fds=close_fds,
|
||||
executable=executable,
|
||||
env=env or os.environ,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
out, err = p.communicate()
|
||||
|
||||
if p.returncode != 0:
|
||||
raise subprocess.CalledProcessError(p.returncode, cmd, out, err)
|
||||
else:
|
||||
return out.decode("utf-8")
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from funcy import cached_property, memoize, wrap_with
|
||||
|
||||
from dvc import prompt
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from ._callback import DEFAULT_CALLBACK
|
||||
from .fsspec_wrapper import AnyFSPath, FSSpecWrapper, NoDirectoriesMixin
|
||||
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
@memoize
|
||||
def ask_password(host, user):
|
||||
return prompt.password(
|
||||
"Enter a password for "
|
||||
"host '{host}' user '{user}'".format(host=host, user=user)
|
||||
)
|
||||
|
||||
|
||||
def make_context(ssl_verify):
|
||||
if isinstance(ssl_verify, bool) or ssl_verify is None:
|
||||
return ssl_verify
|
||||
|
||||
# If this is a path, then we will create an
|
||||
# SSL context for it, and load the given certificate.
|
||||
import ssl
|
||||
|
||||
context = ssl.create_default_context()
|
||||
context.load_verify_locations(ssl_verify)
|
||||
return context
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class HTTPFileSystem(NoDirectoriesMixin, FSSpecWrapper):
|
||||
scheme = Schemes.HTTP
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
REQUIRES = {"aiohttp": "aiohttp", "aiohttp-retry": "aiohttp_retry"}
|
||||
CAN_TRAVERSE = False
|
||||
|
||||
SESSION_RETRIES = 5
|
||||
SESSION_BACKOFF_FACTOR = 0.1
|
||||
REQUEST_TIMEOUT = 60
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
import aiohttp
|
||||
from fsspec.asyn import fsspec_loop
|
||||
|
||||
from dvc.config import ConfigError
|
||||
|
||||
credentials = {}
|
||||
client_kwargs = credentials.setdefault("client_kwargs", {})
|
||||
|
||||
if config.get("auth"):
|
||||
user = config.get("user")
|
||||
password = config.get("password")
|
||||
custom_auth_header = config.get("custom_auth_header")
|
||||
|
||||
if password is None and config.get("ask_password"):
|
||||
password = ask_password(config.get("url"), user or "custom")
|
||||
|
||||
auth_method = config["auth"]
|
||||
if auth_method == "basic":
|
||||
if user is None or password is None:
|
||||
raise ConfigError(
|
||||
"HTTP 'basic' authentication require both "
|
||||
"'user' and 'password'"
|
||||
)
|
||||
|
||||
client_kwargs["auth"] = aiohttp.BasicAuth(user, password)
|
||||
elif auth_method == "custom":
|
||||
if custom_auth_header is None or password is None:
|
||||
raise ConfigError(
|
||||
"HTTP 'custom' authentication require both "
|
||||
"'custom_auth_header' and 'password'"
|
||||
)
|
||||
credentials["headers"] = {custom_auth_header: password}
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
f"Auth method {auth_method!r} is not supported."
|
||||
)
|
||||
|
||||
if "ssl_verify" in config:
|
||||
with fsspec_loop():
|
||||
client_kwargs["connector"] = aiohttp.TCPConnector(
|
||||
ssl=make_context(config["ssl_verify"])
|
||||
)
|
||||
|
||||
# Allow reading proxy configurations from the environment.
|
||||
client_kwargs["trust_env"] = True
|
||||
|
||||
credentials["get_client"] = self.get_client
|
||||
self.upload_method = config.get("method", "POST")
|
||||
return credentials
|
||||
|
||||
async def get_client(self, **kwargs):
|
||||
import aiohttp
|
||||
from aiohttp_retry import ExponentialRetry, RetryClient
|
||||
|
||||
kwargs["retry_options"] = ExponentialRetry(
|
||||
attempts=self.SESSION_RETRIES,
|
||||
factor=self.SESSION_BACKOFF_FACTOR,
|
||||
max_timeout=self.REQUEST_TIMEOUT,
|
||||
exceptions=[aiohttp.ClientError],
|
||||
)
|
||||
|
||||
# The default timeout for the aiohttp is 300 seconds
|
||||
# which is too low for DVC's interactions (especially
|
||||
# on the read) when dealing with large data blobs. We
|
||||
# unlimit the total time to read, and only limit the
|
||||
# time that is spent when connecting to the remote server.
|
||||
kwargs["timeout"] = aiohttp.ClientTimeout(
|
||||
total=None,
|
||||
connect=self.REQUEST_TIMEOUT,
|
||||
sock_connect=self.REQUEST_TIMEOUT,
|
||||
sock_read=None,
|
||||
)
|
||||
|
||||
return RetryClient(**kwargs)
|
||||
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from fsspec.implementations.http import (
|
||||
HTTPFileSystem as _HTTPFileSystem,
|
||||
)
|
||||
|
||||
return _HTTPFileSystem(**self.fs_args)
|
||||
|
||||
def unstrip_protocol(self, path: str) -> str:
|
||||
return path
|
||||
|
||||
def put_file(
|
||||
self,
|
||||
from_file: AnyFSPath,
|
||||
to_info: AnyFSPath,
|
||||
callback: Any = DEFAULT_CALLBACK,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
kwargs.setdefault("method", self.upload_method)
|
||||
super().put_file(from_file, to_info, callback=callback, **kwargs)
|
||||
@@ -1,7 +0,0 @@
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .http import HTTPFileSystem
|
||||
|
||||
|
||||
class HTTPSFileSystem(HTTPFileSystem): # pylint:disable=abstract-method
|
||||
scheme = Schemes.HTTPS
|
||||
-164
@@ -1,164 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.system import System
|
||||
from dvc.utils import tmp_fname
|
||||
from dvc.utils.fs import copy_fobj_to_file, copyfile, makedirs, move, remove
|
||||
|
||||
from ._callback import DEFAULT_CALLBACK
|
||||
from .base import FileSystem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalFileSystem(FileSystem):
|
||||
sep = os.sep
|
||||
|
||||
scheme = Schemes.LOCAL
|
||||
PARAM_CHECKSUM = "md5"
|
||||
PARAM_PATH = "path"
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
|
||||
def __init__(self, **config):
|
||||
from fsspec.implementations.local import LocalFileSystem as LocalFS
|
||||
|
||||
super().__init__(**config)
|
||||
self.fs = LocalFS()
|
||||
|
||||
@staticmethod
|
||||
def open(path, mode="r", encoding=None, **kwargs):
|
||||
return open(path, mode=mode, encoding=encoding)
|
||||
|
||||
def exists(self, path) -> bool:
|
||||
# TODO: replace this with os.path.exists once the problem is fixed on
|
||||
# the fsspec https://github.com/intake/filesystem_spec/issues/742
|
||||
return os.path.lexists(path)
|
||||
|
||||
def checksum(self, path) -> str:
|
||||
from fsspec.utils import tokenize
|
||||
|
||||
st = os.stat(path)
|
||||
|
||||
return str(int(tokenize([st.st_ino, st.st_mtime, st.st_size]), 16))
|
||||
|
||||
def isfile(self, path) -> bool:
|
||||
return os.path.isfile(path)
|
||||
|
||||
def isdir(self, path) -> bool:
|
||||
return os.path.isdir(path)
|
||||
|
||||
def iscopy(self, path):
|
||||
return not (System.is_symlink(path) or System.is_hardlink(path))
|
||||
|
||||
def walk(self, top, topdown=True, onerror=None, **kwargs):
|
||||
"""Directory fs generator.
|
||||
|
||||
See `os.walk` for the docs. Differences:
|
||||
- no support for symlinks
|
||||
"""
|
||||
for root, dirs, files in os.walk(
|
||||
top, topdown=topdown, onerror=onerror
|
||||
):
|
||||
yield os.path.normpath(root), dirs, files
|
||||
|
||||
def find(self, path, prefix=None):
|
||||
for root, _, files in self.walk(path):
|
||||
for file in files:
|
||||
# NOTE: os.path.join is ~5.5 times slower
|
||||
yield f"{root}{os.sep}{file}"
|
||||
|
||||
def is_empty(self, path):
|
||||
if self.isfile(path) and os.path.getsize(path) == 0:
|
||||
return True
|
||||
|
||||
if self.isdir(path) and len(os.listdir(path)) == 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def remove(self, path):
|
||||
remove(path)
|
||||
|
||||
def makedirs(self, path, **kwargs):
|
||||
makedirs(path, exist_ok=kwargs.pop("exist_ok", True))
|
||||
|
||||
def move(self, from_info, to_info):
|
||||
self.makedirs(self.path.parent(to_info))
|
||||
move(from_info, to_info)
|
||||
|
||||
def copy(self, from_info, to_info):
|
||||
tmp_info = self.path.join(self.path.parent(to_info), tmp_fname(""))
|
||||
try:
|
||||
copyfile(from_info, tmp_info)
|
||||
os.rename(tmp_info, to_info)
|
||||
except Exception:
|
||||
self.remove(tmp_info)
|
||||
raise
|
||||
|
||||
def upload_fobj(self, fobj, to_info, **kwargs):
|
||||
self.makedirs(self.path.parent(to_info))
|
||||
tmp_info = self.path.join(self.path.parent(to_info), tmp_fname(""))
|
||||
try:
|
||||
copy_fobj_to_file(fobj, tmp_info)
|
||||
os.rename(tmp_info, to_info)
|
||||
except Exception:
|
||||
self.remove(tmp_info)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def symlink(from_info, to_info):
|
||||
System.symlink(from_info, to_info)
|
||||
|
||||
@staticmethod
|
||||
def is_symlink(path):
|
||||
return System.is_symlink(path)
|
||||
|
||||
def hardlink(self, from_info, to_info):
|
||||
# If there are a lot of empty files (which happens a lot in datasets),
|
||||
# and the cache type is `hardlink`, we might reach link limits and
|
||||
# will get something like: `too many links error`
|
||||
#
|
||||
# This is because all those empty files will have the same hash
|
||||
# (i.e. 68b329da9893e34099c7d8ad5cb9c940), therefore, they will be
|
||||
# linked to the same file in the cache.
|
||||
#
|
||||
# From https://en.wikipedia.org/wiki/Hard_link
|
||||
# * ext4 limits the number of hard links on a file to 65,000
|
||||
# * Windows with NTFS has a limit of 1024 hard links on a file
|
||||
#
|
||||
# That's why we simply create an empty file rather than a link.
|
||||
if self.getsize(from_info) == 0:
|
||||
self.open(to_info, "w").close()
|
||||
|
||||
logger.debug("Created empty file: %s -> %s", from_info, to_info)
|
||||
return
|
||||
|
||||
System.hardlink(from_info, to_info)
|
||||
|
||||
@staticmethod
|
||||
def is_hardlink(path):
|
||||
return System.is_hardlink(path)
|
||||
|
||||
def reflink(self, from_info, to_info):
|
||||
System.reflink(from_info, to_info)
|
||||
|
||||
def info(self, path):
|
||||
return self.fs.info(path)
|
||||
|
||||
def put_file(
|
||||
self, from_file, to_info, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
parent = self.path.parent(to_info)
|
||||
makedirs(parent, exist_ok=True)
|
||||
tmp_file = self.path.join(parent, tmp_fname())
|
||||
copyfile(from_file, tmp_file, callback=callback)
|
||||
os.replace(tmp_file, to_info)
|
||||
|
||||
def get_file(
|
||||
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
copyfile(from_info, to_file, callback=callback)
|
||||
|
||||
|
||||
localfs = LocalFileSystem()
|
||||
@@ -1,33 +0,0 @@
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .fsspec_wrapper import FSSpecWrapper
|
||||
|
||||
|
||||
class MemoryFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
|
||||
scheme = Schemes.MEMORY
|
||||
PARAM_CHECKSUM = "md5"
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
DEFAULT_BLOCKSIZE = 4096
|
||||
|
||||
def __eq__(self, other):
|
||||
# NOTE: all fsspec MemoryFileSystem instances are equivalent and use a
|
||||
# single global store
|
||||
return isinstance(other, type(self))
|
||||
|
||||
__hash__ = FSSpecWrapper.__hash__
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from fsspec.implementations.memory import MemoryFileSystem as MemFS
|
||||
|
||||
return MemFS(**self.fs_args)
|
||||
|
||||
def open(self, *args, **kwargs):
|
||||
with super().open(*args, **kwargs) as fobj:
|
||||
fobj.blocksize = self.DEFAULT_BLOCKSIZE
|
||||
return fobj
|
||||
@@ -1,38 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .fsspec_wrapper import ObjectFSWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class OSSFileSystem(ObjectFSWrapper):
|
||||
scheme = Schemes.OSS
|
||||
REQUIRES = {"ossfs": "ossfs"}
|
||||
PARAM_CHECKSUM = "etag"
|
||||
COPY_POLL_SECONDS = 5
|
||||
LIST_OBJECT_PAGE_SIZE = 100
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
login_info = {}
|
||||
login_info["key"] = config.get("oss_key_id") or os.getenv(
|
||||
"OSS_ACCESS_KEY_ID"
|
||||
)
|
||||
login_info["secret"] = config.get("oss_key_secret") or os.getenv(
|
||||
"OSS_ACCESS_KEY_SECRET"
|
||||
)
|
||||
login_info["endpoint"] = config.get("oss_endpoint")
|
||||
return login_info
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from ossfs import OSSFileSystem as _OSSFileSystem
|
||||
|
||||
return _OSSFileSystem(**self.fs_args)
|
||||
@@ -1,91 +0,0 @@
|
||||
import ntpath
|
||||
import posixpath
|
||||
|
||||
|
||||
class Path:
|
||||
def __init__(self, sep):
|
||||
if sep == posixpath.sep:
|
||||
self.flavour = posixpath
|
||||
elif sep == ntpath.sep:
|
||||
self.flavour = ntpath
|
||||
else:
|
||||
raise ValueError(f"unsupported separator '{sep}'")
|
||||
|
||||
def join(self, *parts):
|
||||
return self.flavour.join(*parts)
|
||||
|
||||
def parts(self, path):
|
||||
drive, path = self.flavour.splitdrive(path.rstrip(self.flavour.sep))
|
||||
|
||||
ret = []
|
||||
while True:
|
||||
path, part = self.flavour.split(path)
|
||||
|
||||
if part:
|
||||
ret.append(part)
|
||||
continue
|
||||
|
||||
if path:
|
||||
ret.append(path)
|
||||
|
||||
break
|
||||
|
||||
ret.reverse()
|
||||
|
||||
if drive:
|
||||
ret = [drive] + ret
|
||||
|
||||
return tuple(ret)
|
||||
|
||||
def parent(self, path):
|
||||
return self.flavour.dirname(path)
|
||||
|
||||
def parents(self, path):
|
||||
parts = self.parts(path)
|
||||
return tuple(
|
||||
self.join(*parts[:length])
|
||||
for length in range(len(parts) - 1, 0, -1)
|
||||
)
|
||||
|
||||
def name(self, path):
|
||||
return self.parts(path)[-1]
|
||||
|
||||
def suffix(self, path):
|
||||
name = self.name(path)
|
||||
_, dot, suffix = name.partition(".")
|
||||
return dot + suffix
|
||||
|
||||
def with_name(self, path, name):
|
||||
parts = list(self.parts(path))
|
||||
parts[-1] = name
|
||||
return self.join(*parts)
|
||||
|
||||
def with_suffix(self, path, suffix):
|
||||
parts = list(self.parts(path))
|
||||
real_path, _, _ = parts[-1].partition(".")
|
||||
parts[-1] = real_path + suffix
|
||||
return self.join(*parts)
|
||||
|
||||
def isin(self, left, right):
|
||||
left_parts = self.parts(left)
|
||||
right_parts = self.parts(right)
|
||||
left_len = len(left_parts)
|
||||
right_len = len(right_parts)
|
||||
return left_len > right_len and left_parts[:right_len] == right_parts
|
||||
|
||||
def isin_or_eq(self, left, right):
|
||||
return left == right or self.isin(left, right)
|
||||
|
||||
def overlaps(self, left, right):
|
||||
# pylint: disable=arguments-out-of-order
|
||||
return self.isin_or_eq(left, right) or self.isin(right, left)
|
||||
|
||||
def relpath(self, path, start):
|
||||
assert start
|
||||
return self.flavour.relpath(path, start=start)
|
||||
|
||||
def relparts(self, path, base):
|
||||
return self.parts(self.relpath(path, base))
|
||||
|
||||
def as_posix(self, path):
|
||||
return path.replace(self.flavour.sep, posixpath.sep)
|
||||
@@ -1,59 +0,0 @@
|
||||
import threading
|
||||
from collections import deque
|
||||
from contextlib import contextmanager
|
||||
|
||||
from funcy import memoize, wrap_with
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_connection(conn_func, *args, **kwargs):
|
||||
pool = get_pool(conn_func, *args, **kwargs)
|
||||
conn = pool.get_connection()
|
||||
try:
|
||||
yield conn
|
||||
except Exception:
|
||||
conn.close()
|
||||
raise
|
||||
else:
|
||||
pool.release(conn)
|
||||
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
@memoize
|
||||
def get_pool(conn_func, *args, **kwargs):
|
||||
return Pool(conn_func, *args, **kwargs)
|
||||
|
||||
|
||||
def close_pools():
|
||||
for pool in get_pool.memory.values():
|
||||
pool.close()
|
||||
get_pool.memory.clear()
|
||||
|
||||
|
||||
class Pool:
|
||||
def __init__(self, conn_func, *conn_args, **conn_kwargs):
|
||||
self._conn_func = conn_func
|
||||
self._conn_args = conn_args
|
||||
self._conn_kwargs = conn_kwargs
|
||||
self._conns = deque()
|
||||
self._closed = False
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
while self._conns:
|
||||
self._conns.pop().close()
|
||||
self._closed = True
|
||||
|
||||
def get_connection(self):
|
||||
try:
|
||||
return self._conns.popleft()
|
||||
except IndexError:
|
||||
return self._conn_func(*self._conn_args, **self._conn_kwargs)
|
||||
|
||||
def release(self, conn):
|
||||
if self._closed:
|
||||
conn.close()
|
||||
else:
|
||||
self._conns.append(conn)
|
||||
-524
@@ -1,524 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from itertools import takewhile
|
||||
from typing import TYPE_CHECKING, Callable, Optional, Tuple, Type, Union
|
||||
|
||||
from funcy import lfilter, wrap_with
|
||||
|
||||
from ._callback import DEFAULT_CALLBACK
|
||||
from .base import FileSystem
|
||||
from .dvc import DvcFileSystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo import Repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RepoFactory = Union[Callable[[str], "Repo"], Type["Repo"]]
|
||||
|
||||
|
||||
def _wrap_walk(dvc_fs, *args, **kwargs):
|
||||
for root, dnames, fnames in dvc_fs.walk(*args, **kwargs):
|
||||
yield dvc_fs.path.join(dvc_fs.repo.root_dir, root), dnames, fnames
|
||||
|
||||
|
||||
class RepoFileSystem(FileSystem): # pylint:disable=abstract-method
|
||||
"""DVC + git-tracked files fs.
|
||||
|
||||
Args:
|
||||
repo: DVC or git repo.
|
||||
subrepos: traverse to subrepos (by default, it ignores subrepos)
|
||||
repo_factory: A function to initialize subrepo with, default is Repo.
|
||||
kwargs: Additional keyword arguments passed to the `DvcFileSystem()`.
|
||||
"""
|
||||
|
||||
sep = os.sep
|
||||
|
||||
scheme = "local"
|
||||
PARAM_CHECKSUM = "md5"
|
||||
PARAM_REPO_URL = "repo_url"
|
||||
PARAM_REPO_ROOT = "repo_root"
|
||||
PARAM_REV = "rev"
|
||||
PARAM_CACHE_DIR = "cache_dir"
|
||||
PARAM_CACHE_TYPES = "cache_types"
|
||||
PARAM_SUBREPOS = "subrepos"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo: Optional["Repo"] = None,
|
||||
subrepos=False,
|
||||
repo_factory: RepoFactory = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
from dvc.utils.collections import PathStringTrie
|
||||
|
||||
if repo is None:
|
||||
repo, repo_factory = self._repo_from_fs_config(
|
||||
subrepos=subrepos, **kwargs
|
||||
)
|
||||
|
||||
if not repo_factory:
|
||||
from dvc.repo import Repo
|
||||
|
||||
self.repo_factory: RepoFactory = Repo
|
||||
else:
|
||||
self.repo_factory = repo_factory
|
||||
|
||||
self._main_repo = repo
|
||||
self.hash_jobs = repo.fs.hash_jobs
|
||||
self.root_dir: str = repo.root_dir
|
||||
self._traverse_subrepos = subrepos
|
||||
|
||||
self._subrepos_trie = PathStringTrie()
|
||||
"""Keeps track of each and every path with the corresponding repo."""
|
||||
|
||||
self._subrepos_trie[self.root_dir] = repo
|
||||
|
||||
self._dvcfss = {}
|
||||
"""Keep a dvcfs instance of each repo."""
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return {
|
||||
self.PARAM_REPO_URL: self.repo_url,
|
||||
self.PARAM_REPO_ROOT: self.root_dir,
|
||||
self.PARAM_REV: getattr(self._main_repo.fs, "rev", None),
|
||||
self.PARAM_CACHE_DIR: os.path.abspath(
|
||||
self._main_repo.odb.local.cache_dir
|
||||
),
|
||||
self.PARAM_CACHE_TYPES: self._main_repo.odb.local.cache_types,
|
||||
self.PARAM_SUBREPOS: self._traverse_subrepos,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _repo_from_fs_config(
|
||||
cls, **config
|
||||
) -> Tuple["Repo", Optional["RepoFactory"]]:
|
||||
from dvc.external_repo import erepo_factory, external_repo
|
||||
from dvc.repo import Repo
|
||||
|
||||
url = config.get(cls.PARAM_REPO_URL)
|
||||
root = config.get(cls.PARAM_REPO_ROOT)
|
||||
assert url or root
|
||||
|
||||
def _open(*args, **kwargs):
|
||||
# NOTE: if original repo was an erepo (and has a URL),
|
||||
# we cannot use Repo.open() since it will skip erepo
|
||||
# cache/remote setup for local URLs
|
||||
if url is None:
|
||||
return Repo.open(*args, **kwargs)
|
||||
return external_repo(*args, **kwargs)
|
||||
|
||||
cache_dir = config.get(cls.PARAM_CACHE_DIR)
|
||||
cache_config = (
|
||||
{}
|
||||
if not cache_dir
|
||||
else {
|
||||
"cache": {
|
||||
"dir": cache_dir,
|
||||
"type": config.get(cls.PARAM_CACHE_TYPES),
|
||||
}
|
||||
}
|
||||
)
|
||||
repo_kwargs: dict = {
|
||||
"rev": config.get(cls.PARAM_REV),
|
||||
"subrepos": config.get(cls.PARAM_SUBREPOS, False),
|
||||
"uninitialized": True,
|
||||
}
|
||||
factory: Optional["RepoFactory"] = None
|
||||
if url is None:
|
||||
repo_kwargs["config"] = cache_config
|
||||
else:
|
||||
repo_kwargs["cache_dir"] = cache_dir
|
||||
factory = erepo_factory(url, cache_config)
|
||||
|
||||
with _open(
|
||||
url if url else root,
|
||||
**repo_kwargs,
|
||||
) as repo:
|
||||
return repo, factory
|
||||
|
||||
def _get_repo(self, path: str) -> Optional["Repo"]:
|
||||
"""Returns repo that the path falls in, using prefix.
|
||||
|
||||
If the path is already tracked/collected, it just returns the repo.
|
||||
|
||||
Otherwise, it collects the repos that might be in the path's parents
|
||||
and then returns the appropriate one.
|
||||
"""
|
||||
repo = self._subrepos_trie.get(path)
|
||||
if repo:
|
||||
return repo
|
||||
|
||||
prefix, repo = self._subrepos_trie.longest_prefix(path)
|
||||
if not prefix:
|
||||
return None
|
||||
|
||||
parents = (parent for parent in self.path.parents(path))
|
||||
dirs = [path] + list(takewhile(lambda p: p != prefix, parents))
|
||||
dirs.reverse()
|
||||
self._update(dirs, starting_repo=repo)
|
||||
return self._subrepos_trie.get(path)
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
def _update(self, dirs, starting_repo):
|
||||
"""Checks for subrepo in directories and updates them."""
|
||||
repo = starting_repo
|
||||
for d in dirs:
|
||||
if self._is_dvc_repo(d):
|
||||
repo = self.repo_factory(
|
||||
d,
|
||||
fs=self._main_repo.fs,
|
||||
repo_factory=self.repo_factory,
|
||||
)
|
||||
self._dvcfss[repo.root_dir] = DvcFileSystem(repo=repo)
|
||||
self._subrepos_trie[d] = repo
|
||||
|
||||
def _is_dvc_repo(self, dir_path):
|
||||
"""Check if the directory is a dvc repo."""
|
||||
if not self._traverse_subrepos:
|
||||
return False
|
||||
|
||||
from dvc.repo import Repo
|
||||
|
||||
repo_path = os.path.join(dir_path, Repo.DVC_DIR)
|
||||
return self._main_repo.fs.isdir(repo_path)
|
||||
|
||||
def _get_fs_pair(
|
||||
self, path
|
||||
) -> Tuple[FileSystem, Optional[DvcFileSystem], str]:
|
||||
"""
|
||||
Returns a pair of fss based on repo the path falls in, using prefix.
|
||||
"""
|
||||
path = os.path.abspath(path)
|
||||
|
||||
# fallback to the top-level repo if repo was not found
|
||||
# this can happen if the path is outside of the repo
|
||||
repo = self._get_repo(path) or self._main_repo
|
||||
|
||||
dvc_fs = self._dvcfss.get(repo.root_dir)
|
||||
|
||||
if path.startswith(repo.root_dir):
|
||||
dvc_path = path[len(repo.root_dir) + 1 :]
|
||||
else:
|
||||
dvc_path = path
|
||||
|
||||
return repo.fs, dvc_fs, dvc_path
|
||||
|
||||
def open(
|
||||
self, path, mode="r", encoding="utf-8", **kwargs
|
||||
): # pylint: disable=arguments-renamed
|
||||
if "b" in mode:
|
||||
encoding = None
|
||||
|
||||
fs, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
try:
|
||||
return fs.open(path, mode=mode, encoding=encoding)
|
||||
except FileNotFoundError:
|
||||
if not dvc_fs:
|
||||
raise
|
||||
|
||||
return dvc_fs.open(dvc_path, mode=mode, encoding=encoding, **kwargs)
|
||||
|
||||
def exists(self, path) -> bool:
|
||||
path = os.path.abspath(path)
|
||||
|
||||
fs, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
if not dvc_fs:
|
||||
return fs.exists(path)
|
||||
|
||||
if dvc_fs.repo.dvcignore.is_ignored(fs, path):
|
||||
return False
|
||||
|
||||
if fs.exists(path):
|
||||
return True
|
||||
|
||||
if not dvc_fs.exists(dvc_path):
|
||||
return False
|
||||
|
||||
for p in self.path.parents(path):
|
||||
try:
|
||||
if fs.info(p)["type"] != "directory":
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
def isdir(self, path): # pylint: disable=arguments-renamed
|
||||
path = os.path.abspath(path)
|
||||
|
||||
fs, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
if dvc_fs and dvc_fs.repo.dvcignore.is_ignored_dir(path):
|
||||
return False
|
||||
|
||||
try:
|
||||
info = fs.info(path)
|
||||
return info["type"] == "directory"
|
||||
except (OSError, ValueError):
|
||||
# from CPython's os.path.isdir()
|
||||
pass
|
||||
|
||||
if not dvc_fs:
|
||||
return False
|
||||
|
||||
try:
|
||||
info = dvc_fs.info(dvc_path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
for p in self.path.parents(path):
|
||||
try:
|
||||
if fs.info(p)["type"] != "directory":
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
return info["type"] == "directory"
|
||||
|
||||
def isdvc(self, path, **kwargs):
|
||||
_, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
return dvc_fs is not None and dvc_fs.isdvc(dvc_path, **kwargs)
|
||||
|
||||
def isfile(self, path): # pylint: disable=arguments-renamed
|
||||
path = os.path.abspath(path)
|
||||
|
||||
fs, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
if dvc_fs and dvc_fs.repo.dvcignore.is_ignored_file(path):
|
||||
return False
|
||||
|
||||
try:
|
||||
info = fs.info(path)
|
||||
return info["type"] == "file"
|
||||
except (OSError, ValueError):
|
||||
# from CPython's os.path.isfile()
|
||||
pass
|
||||
|
||||
if not dvc_fs:
|
||||
return False
|
||||
|
||||
try:
|
||||
info = dvc_fs.info(dvc_path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
for p in self.path.parents(path):
|
||||
try:
|
||||
if fs.info(p)["type"] != "directory":
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
return info["type"] == "file"
|
||||
|
||||
def _dvc_walk(self, walk):
|
||||
try:
|
||||
root, dirs, files = next(walk)
|
||||
except StopIteration:
|
||||
return
|
||||
yield root, dirs, files
|
||||
for _ in dirs:
|
||||
yield from self._dvc_walk(walk)
|
||||
|
||||
def _subrepo_walk(self, dir_path, **kwargs):
|
||||
"""Walk into a new repo.
|
||||
|
||||
NOTE: subrepo will only be discovered when walking if
|
||||
ignore_subrepos is set to False.
|
||||
"""
|
||||
fs, dvc_fs, dvc_path = self._get_fs_pair(dir_path)
|
||||
fs_walk = fs.walk(dir_path, topdown=True)
|
||||
if dvc_fs:
|
||||
dvc_walk = _wrap_walk(dvc_fs, dvc_path, topdown=True, **kwargs)
|
||||
else:
|
||||
dvc_walk = None
|
||||
yield from self._walk(fs_walk, dvc_walk, **kwargs)
|
||||
|
||||
def _walk(self, repo_walk, dvc_walk=None, dvcfiles=False):
|
||||
from dvc.dvcfile import is_valid_filename
|
||||
from dvc.ignore import DvcIgnore
|
||||
|
||||
assert repo_walk
|
||||
try:
|
||||
_, dvc_dirs, dvc_fnames = (
|
||||
next(dvc_walk) if dvc_walk else (None, [], [])
|
||||
)
|
||||
repo_root, repo_dirs, repo_fnames = next(repo_walk)
|
||||
except StopIteration:
|
||||
return
|
||||
|
||||
# separate subdirs into shared dirs, dvc-only dirs, repo-only dirs
|
||||
dvc_set = set(dvc_dirs)
|
||||
repo_set = set(repo_dirs)
|
||||
dvc_only = list(dvc_set - repo_set)
|
||||
repo_only = list(repo_set - dvc_set)
|
||||
shared = list(dvc_set & repo_set)
|
||||
dirs = shared + dvc_only + repo_only
|
||||
|
||||
def _func(fname):
|
||||
if dvcfiles:
|
||||
return True
|
||||
|
||||
return not (
|
||||
is_valid_filename(fname) or fname == DvcIgnore.DVCIGNORE_FILE
|
||||
)
|
||||
|
||||
# merge file lists
|
||||
files = set(filter(_func, dvc_fnames + repo_fnames))
|
||||
|
||||
yield repo_root, dirs, list(files)
|
||||
|
||||
def is_dvc_repo(d):
|
||||
return self._is_dvc_repo(os.path.join(repo_root, d))
|
||||
|
||||
# remove subrepos to prevent it from being traversed
|
||||
subrepos = set(filter(is_dvc_repo, repo_only))
|
||||
# set dir order for next recursion level - shared dirs first so that
|
||||
# next() for both generators recurses into the same shared directory
|
||||
dvc_dirs[:] = [dirname for dirname in dirs if dirname in dvc_set]
|
||||
repo_dirs[:] = lfilter(lambda d: d in (repo_set - subrepos), dirs)
|
||||
|
||||
for dirname in dirs:
|
||||
if dirname in subrepos:
|
||||
dir_path = os.path.join(repo_root, dirname)
|
||||
yield from self._subrepo_walk(dir_path, dvcfiles=dvcfiles)
|
||||
elif dirname in shared:
|
||||
yield from self._walk(repo_walk, dvc_walk, dvcfiles=dvcfiles)
|
||||
elif dirname in dvc_set:
|
||||
yield from self._dvc_walk(dvc_walk)
|
||||
elif dirname in repo_set:
|
||||
yield from self._walk(repo_walk, None, dvcfiles=dvcfiles)
|
||||
|
||||
def walk(self, top, topdown=True, onerror=None, **kwargs):
|
||||
"""Walk and merge both DVC and repo fss.
|
||||
|
||||
Args:
|
||||
top: path to walk from
|
||||
topdown: if True, fs will be walked from top down.
|
||||
onerror: if set, onerror function will be called if an error
|
||||
occurs (by default errors are ignored).
|
||||
dvcfiles: if True, dvcfiles will be included in the files list
|
||||
for walked directories.
|
||||
|
||||
Any kwargs will be passed into methods used for fetching and/or
|
||||
streaming DVC outs from remotes.
|
||||
"""
|
||||
assert topdown
|
||||
|
||||
if not self.exists(top):
|
||||
if onerror is not None:
|
||||
onerror(FileNotFoundError(top))
|
||||
return
|
||||
|
||||
if not self.isdir(top):
|
||||
if onerror is not None:
|
||||
onerror(NotADirectoryError(top))
|
||||
return
|
||||
|
||||
repo = self._get_repo(os.path.abspath(top))
|
||||
dvcfiles = kwargs.pop("dvcfiles", False)
|
||||
|
||||
fs, dvc_fs, dvc_path = self._get_fs_pair(top)
|
||||
repo_exists = fs.exists(top)
|
||||
|
||||
repo_walk = repo.dvcignore.walk(
|
||||
fs, top, topdown=topdown, onerror=onerror, **kwargs
|
||||
)
|
||||
|
||||
if not dvc_fs or (repo_exists and dvc_fs.isdvc(dvc_path)):
|
||||
yield from self._walk(repo_walk, None, dvcfiles=dvcfiles)
|
||||
return
|
||||
|
||||
if not repo_exists:
|
||||
yield from _wrap_walk(
|
||||
dvc_fs, dvc_path, topdown=topdown, onerror=onerror, **kwargs
|
||||
)
|
||||
|
||||
dvc_walk = None
|
||||
if dvc_fs.exists(dvc_path):
|
||||
dvc_walk = _wrap_walk(
|
||||
dvc_fs, dvc_path, topdown=topdown, onerror=onerror, **kwargs
|
||||
)
|
||||
|
||||
yield from self._walk(repo_walk, dvc_walk, dvcfiles=dvcfiles)
|
||||
|
||||
def find(self, path, prefix=None):
|
||||
for root, _, files in self.walk(path):
|
||||
for fname in files:
|
||||
yield self.path.join(root, fname)
|
||||
|
||||
def get_file(
|
||||
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
fs, dvc_fs, dvc_path = self._get_fs_pair(from_info)
|
||||
try:
|
||||
fs.get_file( # pylint: disable=protected-access
|
||||
from_info, to_file, callback=callback, **kwargs
|
||||
)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
if not dvc_fs:
|
||||
raise
|
||||
|
||||
dvc_fs.get_file( # pylint: disable=protected-access
|
||||
dvc_path, to_file, callback=callback, **kwargs
|
||||
)
|
||||
|
||||
def info(self, path):
|
||||
fs, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
try:
|
||||
dvc_info = dvc_fs.info(dvc_path)
|
||||
except FileNotFoundError:
|
||||
dvc_info = None
|
||||
|
||||
try:
|
||||
from dvc.utils import is_exec
|
||||
|
||||
fs_info = fs.info(path)
|
||||
fs_info["repo"] = dvc_fs.repo
|
||||
fs_info["isout"] = (
|
||||
dvc_info.get("isout", False) if dvc_info else False
|
||||
)
|
||||
fs_info["outs"] = dvc_info["outs"] if dvc_info else None
|
||||
fs_info["isdvc"] = dvc_info["isdvc"] if dvc_info else False
|
||||
fs_info["meta"] = dvc_info.get("meta") if dvc_info else None
|
||||
|
||||
isexec = False
|
||||
if dvc_info:
|
||||
isexec = dvc_info["isexec"]
|
||||
elif fs_info["type"] == "file":
|
||||
isexec = is_exec(fs_info["mode"])
|
||||
fs_info["isexec"] = isexec
|
||||
return fs_info
|
||||
|
||||
except FileNotFoundError:
|
||||
if not dvc_info:
|
||||
raise
|
||||
|
||||
dvc_info["repo"] = dvc_fs.repo
|
||||
dvc_info["isdvc"] = True
|
||||
return dvc_info
|
||||
|
||||
def checksum(self, path):
|
||||
fs, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
try:
|
||||
return fs.checksum(path)
|
||||
except FileNotFoundError:
|
||||
return dvc_fs.checksum(dvc_path)
|
||||
-232
@@ -1,232 +0,0 @@
|
||||
import functools
|
||||
import os
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from ._callback import DEFAULT_CALLBACK
|
||||
from .fsspec_wrapper import ObjectFSWrapper
|
||||
|
||||
_AWS_CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".aws", "config")
|
||||
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class BaseS3FileSystem(ObjectFSWrapper):
|
||||
scheme = Schemes.S3
|
||||
REQUIRES = {"s3fs": "s3fs", "boto3": "boto3"}
|
||||
PARAM_CHECKSUM = "etag"
|
||||
|
||||
_GRANTS = {
|
||||
"grant_full_control": "GrantFullControl",
|
||||
"grant_read": "GrantRead",
|
||||
"grant_read_acp": "GrantReadACP",
|
||||
"grant_write_acp": "GrantWriteACP",
|
||||
}
|
||||
|
||||
_TRANSFER_CONFIG_ALIASES = {
|
||||
"max_queue_size": "max_io_queue",
|
||||
"max_concurrent_requests": "max_concurrency",
|
||||
"multipart_threshold": "multipart_threshold",
|
||||
"multipart_chunksize": "multipart_chunksize",
|
||||
}
|
||||
|
||||
def _split_s3_config(self, s3_config):
|
||||
"""Splits the general s3 config into 2 different config
|
||||
objects, one for transfer.TransferConfig and other is the
|
||||
general session config"""
|
||||
|
||||
from boto3.s3.transfer import TransferConfig
|
||||
|
||||
from dvc.utils import conversions
|
||||
|
||||
config, transfer_config = {}, {}
|
||||
for key, value in s3_config.items():
|
||||
if key in self._TRANSFER_CONFIG_ALIASES:
|
||||
if key in {"multipart_chunksize", "multipart_threshold"}:
|
||||
# cast human readable sizes (like 24MiB) to integers
|
||||
value = conversions.human_readable_to_bytes(value)
|
||||
else:
|
||||
value = int(value)
|
||||
transfer_config[self._TRANSFER_CONFIG_ALIASES[key]] = value
|
||||
else:
|
||||
config[key] = value
|
||||
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self._transfer_config = TransferConfig(**transfer_config)
|
||||
return config
|
||||
|
||||
def _load_aws_config_file(self, profile):
|
||||
from botocore.configloader import load_config
|
||||
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self._transfer_config = None
|
||||
config_path = os.environ.get("AWS_CONFIG_FILE", _AWS_CONFIG_PATH)
|
||||
if not os.path.exists(config_path):
|
||||
return {}
|
||||
|
||||
config = load_config(config_path)
|
||||
profile_config = config["profiles"].get(profile or "default")
|
||||
if not profile_config:
|
||||
return {}
|
||||
|
||||
s3_config = profile_config.get("s3", {})
|
||||
return self._split_s3_config(s3_config)
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
from dvc.config import ConfigError
|
||||
from dvc.utils.flatten import flatten, unflatten
|
||||
|
||||
login_info = defaultdict(dict)
|
||||
|
||||
# credentials
|
||||
login_info["key"] = config.get("access_key_id")
|
||||
login_info["secret"] = config.get("secret_access_key")
|
||||
login_info["token"] = config.get("session_token")
|
||||
|
||||
# session configuration
|
||||
login_info["profile"] = config.get("profile")
|
||||
login_info["use_ssl"] = config.get("use_ssl", True)
|
||||
|
||||
# extra client configuration
|
||||
client = login_info["client_kwargs"]
|
||||
client["region_name"] = config.get("region")
|
||||
client["endpoint_url"] = config.get("endpointurl")
|
||||
client["verify"] = config.get("ssl_verify")
|
||||
|
||||
# timeout configuration
|
||||
config_kwargs = login_info["config_kwargs"]
|
||||
config_kwargs["read_timeout"] = config.get("read_timeout")
|
||||
config_kwargs["connect_timeout"] = config.get("connect_timeout")
|
||||
|
||||
# encryptions
|
||||
additional = login_info["s3_additional_kwargs"]
|
||||
additional["ServerSideEncryption"] = config.get("sse")
|
||||
additional["SSEKMSKeyId"] = config.get("sse_kms_key_id")
|
||||
additional["ACL"] = config.get("acl")
|
||||
for grant_option, grant_key in self._GRANTS.items():
|
||||
if config.get(grant_option):
|
||||
if additional["ACL"]:
|
||||
raise ConfigError(
|
||||
"`acl` and `grant_*` AWS S3 config options "
|
||||
"are mutually exclusive"
|
||||
)
|
||||
additional[grant_key] = config[grant_option]
|
||||
|
||||
# config kwargs
|
||||
session_config = login_info["config_kwargs"]
|
||||
session_config["s3"] = self._load_aws_config_file(
|
||||
login_info["profile"]
|
||||
)
|
||||
|
||||
shared_creds = config.get("credentialpath")
|
||||
if shared_creds:
|
||||
os.environ.setdefault("AWS_SHARED_CREDENTIALS_FILE", shared_creds)
|
||||
|
||||
if (
|
||||
client["region_name"] is None
|
||||
and session_config["s3"].get("region_name") is None
|
||||
and os.getenv("AWS_REGION") is None
|
||||
):
|
||||
# Enable bucket region caching
|
||||
login_info["cache_regions"] = config.get("cache_regions", True)
|
||||
|
||||
config_path = config.get("configpath")
|
||||
if config_path:
|
||||
os.environ.setdefault("AWS_CONFIG_FILE", config_path)
|
||||
|
||||
return unflatten(
|
||||
{
|
||||
key: value
|
||||
for key, value in flatten(login_info).items()
|
||||
if value is not None
|
||||
}
|
||||
)
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from s3fs import S3FileSystem as _S3FileSystem
|
||||
|
||||
return _S3FileSystem(**self.fs_args)
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str) -> str:
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
return infer_storage_options(path)["path"]
|
||||
|
||||
def unstrip_protocol(self, path):
|
||||
return "s3://" + path.lstrip("/")
|
||||
|
||||
|
||||
def _translate_exceptions(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
from s3fs.errors import translate_boto_error
|
||||
|
||||
raise translate_boto_error(exc)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class S3FileSystem(BaseS3FileSystem): # pylint:disable=abstract-method
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def s3(self):
|
||||
import boto3
|
||||
|
||||
login_info = self.fs_args
|
||||
client_kwargs = login_info.get("client_kwargs", {})
|
||||
session_opts = {
|
||||
"profile_name": login_info.get("profile"),
|
||||
"region_name": client_kwargs.get("region_name"),
|
||||
}
|
||||
|
||||
if "key" in login_info:
|
||||
session_opts["aws_access_key_id"] = login_info["key"]
|
||||
if "secret" in login_info:
|
||||
session_opts["aws_secret_access_key"] = login_info["secret"]
|
||||
if "token" in login_info:
|
||||
session_opts["aws_session_token"] = login_info["token"]
|
||||
|
||||
session = boto3.session.Session(**session_opts)
|
||||
|
||||
return session.resource(
|
||||
"s3",
|
||||
endpoint_url=client_kwargs.get("endpoint_url"),
|
||||
use_ssl=login_info["use_ssl"],
|
||||
verify=client_kwargs.get("verify"),
|
||||
)
|
||||
|
||||
def _get_obj(self, path):
|
||||
bucket_name, key, _ = self.fs.split_path(path)
|
||||
bucket = self.s3.Bucket(bucket_name)
|
||||
return bucket.Object(key)
|
||||
|
||||
@_translate_exceptions
|
||||
def put_file(
|
||||
self, from_file, to_info, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
callback.set_size(os.path.getsize(from_file))
|
||||
obj = self._get_obj(to_info)
|
||||
obj.upload_file(
|
||||
from_file,
|
||||
Callback=callback.relative_update,
|
||||
ExtraArgs=self.fs_args.get("s3_additional_kwargs"),
|
||||
Config=self._transfer_config,
|
||||
)
|
||||
self.fs.invalidate_cache(self.path.parent(to_info))
|
||||
|
||||
@_translate_exceptions
|
||||
def get_file(
|
||||
self, from_info, to_info, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
obj = self._get_obj(from_info)
|
||||
callback.set_size(obj.content_length)
|
||||
obj.download_file(to_info, Callback=callback.relative_update)
|
||||
-137
@@ -1,137 +0,0 @@
|
||||
import getpass
|
||||
import os.path
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, memoize, silent, wrap_prop, wrap_with
|
||||
|
||||
from dvc import prompt
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils.fs import as_atomic
|
||||
|
||||
from ._callback import DEFAULT_CALLBACK
|
||||
from .fsspec_wrapper import FSSpecWrapper
|
||||
|
||||
_SSH_TIMEOUT = 60 * 30
|
||||
_SSH_CONFIG_FILE = os.path.expanduser(os.path.join("~", ".ssh", "config"))
|
||||
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
@memoize
|
||||
def ask_password(host, user, port):
|
||||
return prompt.password(
|
||||
"Enter a private key passphrase or a password for "
|
||||
"host '{host}' port '{port}' user '{user}'".format(
|
||||
host=host, port=port, user=user
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class SSHFileSystem(FSSpecWrapper):
|
||||
scheme = Schemes.SSH
|
||||
REQUIRES = {"sshfs": "sshfs"}
|
||||
|
||||
DEFAULT_PORT = 22
|
||||
PARAM_CHECKSUM = "md5"
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str) -> str:
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
return infer_storage_options(path)["path"]
|
||||
|
||||
def unstrip_protocol(self, path: str) -> str:
|
||||
host = self.fs_args["host"]
|
||||
port = self.fs_args["port"]
|
||||
path = path.lstrip("/")
|
||||
return f"ssh://{host}:{port}/{path}"
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
self.CAN_TRAVERSE = True
|
||||
from sshfs.config import parse_config
|
||||
|
||||
login_info = {}
|
||||
|
||||
try:
|
||||
user_ssh_config = parse_config(host=config["host"])
|
||||
except FileNotFoundError:
|
||||
user_ssh_config = {}
|
||||
|
||||
login_info["host"] = user_ssh_config.get("Hostname", config["host"])
|
||||
|
||||
login_info["username"] = (
|
||||
config.get("user")
|
||||
or config.get("username")
|
||||
or user_ssh_config.get("User")
|
||||
or getpass.getuser()
|
||||
)
|
||||
|
||||
login_info["port"] = (
|
||||
config.get("port")
|
||||
or silent(int)(user_ssh_config.get("Port"))
|
||||
or self.DEFAULT_PORT
|
||||
)
|
||||
|
||||
if config.get("ask_password") and config.get("password") is None:
|
||||
config["password"] = ask_password(
|
||||
login_info["host"], login_info["username"], login_info["port"]
|
||||
)
|
||||
|
||||
login_info["password"] = config.get("password")
|
||||
login_info["passphrase"] = config.get("password")
|
||||
|
||||
raw_keys = []
|
||||
if config.get("keyfile"):
|
||||
raw_keys.append(config.get("keyfile"))
|
||||
elif user_ssh_config.get("IdentityFile"):
|
||||
raw_keys.extend(user_ssh_config.get("IdentityFile"))
|
||||
|
||||
if raw_keys:
|
||||
login_info["client_keys"] = [
|
||||
os.path.expanduser(key) for key in raw_keys
|
||||
]
|
||||
|
||||
login_info["timeout"] = config.get("timeout", _SSH_TIMEOUT)
|
||||
|
||||
# These two settings fine tune the asyncssh to use the
|
||||
# fastest encryption algorithm and disable compression
|
||||
# altogether (since it blocking, it is slowing down
|
||||
# the transfers in a considerable rate, and even for
|
||||
# compressible data it is making it extremely slow).
|
||||
# See: https://github.com/ronf/asyncssh/issues/374
|
||||
login_info["encryption_algs"] = [
|
||||
"[email protected]",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
]
|
||||
login_info["compression_algs"] = None
|
||||
|
||||
login_info["gss_auth"] = config.get("gss_auth", False)
|
||||
login_info["agent_forwarding"] = config.get("agent_forwarding", True)
|
||||
login_info["proxy_command"] = user_ssh_config.get("ProxyCommand")
|
||||
|
||||
# We are going to automatically add stuff to known_hosts
|
||||
# something like paramiko's AutoAddPolicy()
|
||||
login_info["known_hosts"] = None
|
||||
return login_info
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from sshfs import SSHFileSystem as _SSHFileSystem
|
||||
|
||||
return _SSHFileSystem(**self.fs_args)
|
||||
|
||||
# Ensure that if an interrupt happens during the transfer, we don't
|
||||
# pollute the cache.
|
||||
|
||||
def upload_fobj(self, fobj, to_info, **kwargs):
|
||||
with as_atomic(self, to_info) as tmp_file:
|
||||
super().upload_fobj(fobj, tmp_file, **kwargs)
|
||||
|
||||
def put_file(
|
||||
self, from_file, to_info, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
with as_atomic(self, to_info) as tmp_file:
|
||||
super().put_file(from_file, tmp_file, callback=callback, **kwargs)
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from .base import RemoteActionNotImplemented
|
||||
from .local import LocalFileSystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base import FileSystem
|
||||
from .types import AnyPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _link(
|
||||
link: "AnyPath",
|
||||
from_fs: "FileSystem",
|
||||
from_path: "AnyPath",
|
||||
to_fs: "FileSystem",
|
||||
to_path: "AnyPath",
|
||||
) -> None:
|
||||
if not isinstance(from_fs, type(to_fs)):
|
||||
raise OSError(errno.EXDEV, "can't link across filesystems")
|
||||
|
||||
try:
|
||||
func = getattr(to_fs, link)
|
||||
func(from_path, to_path)
|
||||
except (OSError, AttributeError, RemoteActionNotImplemented) as exc:
|
||||
# NOTE: there are so many potential error codes that a link call can
|
||||
# throw, that is safer to just catch them all and try another link.
|
||||
raise OSError(
|
||||
errno.ENOTSUP, f"'{link}' is not supported by {type(from_fs)}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _copy(
|
||||
from_fs: "FileSystem",
|
||||
from_path: "AnyPath",
|
||||
to_fs: "FileSystem",
|
||||
to_path: "AnyPath",
|
||||
) -> None:
|
||||
if isinstance(from_fs, LocalFileSystem):
|
||||
return to_fs.upload(from_path, to_path)
|
||||
|
||||
if isinstance(to_fs, LocalFileSystem):
|
||||
return from_fs.download_file(from_path, to_path)
|
||||
|
||||
with from_fs.open(from_path, mode="rb") as fobj:
|
||||
size = from_fs.getsize(from_path)
|
||||
return to_fs.upload(fobj, to_path, total=size)
|
||||
|
||||
|
||||
def _try_links(
|
||||
links: List["AnyPath"],
|
||||
from_fs: "FileSystem",
|
||||
from_path: "AnyPath",
|
||||
to_fs: "FileSystem",
|
||||
to_path: "AnyPath",
|
||||
) -> None:
|
||||
error = None
|
||||
while links:
|
||||
link = links[0]
|
||||
|
||||
if link == "copy":
|
||||
return _copy(from_fs, from_path, to_fs, to_path)
|
||||
|
||||
try:
|
||||
return _link(link, from_fs, from_path, to_fs, to_path)
|
||||
except OSError as exc:
|
||||
if exc.errno not in [errno.ENOTSUP, errno.EXDEV]:
|
||||
raise
|
||||
error = exc
|
||||
|
||||
del links[0]
|
||||
|
||||
raise OSError(
|
||||
errno.ENOTSUP, "no more link types left to try out"
|
||||
) from error
|
||||
|
||||
|
||||
def transfer(
|
||||
from_fs: "FileSystem",
|
||||
from_path: "AnyPath",
|
||||
to_fs: "FileSystem",
|
||||
to_path: "AnyPath",
|
||||
hardlink: bool = False,
|
||||
links: Optional[List["AnyPath"]] = None,
|
||||
) -> None:
|
||||
try:
|
||||
assert not (hardlink and links)
|
||||
if hardlink:
|
||||
links = links or ["reflink", "hardlink", "copy"]
|
||||
else:
|
||||
links = links or ["reflink", "copy"]
|
||||
_try_links(links, from_fs, from_path, to_fs, to_path)
|
||||
except OSError as exc:
|
||||
# If the target file already exists, we are going to simply
|
||||
# ignore the exception (#4992).
|
||||
#
|
||||
# On Windows, it is not always guaranteed that you'll get
|
||||
# FileExistsError (it might be PermissionError or a bare OSError)
|
||||
# but all of those exceptions raised from the original
|
||||
# FileExistsError so we have a separate check for that.
|
||||
if isinstance(exc, FileExistsError) or (
|
||||
os.name == "nt"
|
||||
and exc.__context__
|
||||
and isinstance(exc.__context__, FileExistsError)
|
||||
):
|
||||
logger.debug("'%s' file already exists, skipping", to_path)
|
||||
return None
|
||||
|
||||
raise
|
||||
|
||||
|
||||
def _test_link(
|
||||
link: "AnyPath",
|
||||
from_fs: "FileSystem",
|
||||
from_file: "AnyPath",
|
||||
to_fs: "FileSystem",
|
||||
to_file: "AnyPath",
|
||||
) -> bool:
|
||||
try:
|
||||
_try_links([link], from_fs, from_file, to_fs, to_file)
|
||||
except OSError:
|
||||
logger.debug("", exc_info=True)
|
||||
return False
|
||||
|
||||
try:
|
||||
_is_link_func = getattr(to_fs, f"is_{link}")
|
||||
return _is_link_func(to_file)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_links(
|
||||
links: List["AnyPath"],
|
||||
from_fs: "FileSystem",
|
||||
from_path: "AnyPath",
|
||||
to_fs: "FileSystem",
|
||||
to_path: "AnyPath",
|
||||
) -> List["AnyPath"]:
|
||||
from dvc.utils import tmp_fname
|
||||
|
||||
from_file = from_fs.path.join(from_path, tmp_fname())
|
||||
to_file = to_fs.path.join(
|
||||
to_fs.path.parent(to_path),
|
||||
tmp_fname(),
|
||||
)
|
||||
|
||||
from_fs.makedirs(from_fs.path.parent(from_file))
|
||||
with from_fs.open(from_file, "wb") as fobj:
|
||||
fobj.write(b"test")
|
||||
to_fs.makedirs(to_fs.path.parent(to_file))
|
||||
|
||||
ret = []
|
||||
try:
|
||||
for link in links:
|
||||
try:
|
||||
if _test_link(link, from_fs, from_file, to_fs, to_file):
|
||||
ret.append(link)
|
||||
finally:
|
||||
to_fs.remove(to_file)
|
||||
finally:
|
||||
from_fs.remove(from_file)
|
||||
|
||||
return ret
|
||||
@@ -1,99 +0,0 @@
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .fsspec_wrapper import FSSpecWrapper
|
||||
from .http import ask_password
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebDAVFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
|
||||
scheme = Schemes.WEBDAV
|
||||
CAN_TRAVERSE = True
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
REQUIRES = {"webdav4": "webdav4"}
|
||||
PARAM_CHECKSUM = "etag"
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
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),
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str) -> str:
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
return infer_storage_options(path)["path"].lstrip("/")
|
||||
|
||||
def unstrip_protocol(self, path: str) -> str:
|
||||
return self.fs_args["base_url"] + "/" + path
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
parsed = urlparse(urlpath)
|
||||
scheme = parsed.scheme.replace("webdav", "http")
|
||||
return {
|
||||
"prefix": parsed.path.rstrip("/"),
|
||||
"host": urlunparse((scheme, parsed.netloc, "", None, None, None)),
|
||||
"url": urlunparse(
|
||||
(
|
||||
scheme,
|
||||
parsed.netloc,
|
||||
parsed.path.rstrip("/"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
),
|
||||
"user": parsed.username,
|
||||
}
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
user = config.get("user", None)
|
||||
password = config.get("password", None)
|
||||
|
||||
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)
|
||||
|
||||
return {"headers": headers, "auth": auth}
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from webdav4.fsspec import WebdavFileSystem
|
||||
|
||||
return WebdavFileSystem(**self.fs_args)
|
||||
|
||||
def upload_fobj(self, fobj, to_info, **kwargs):
|
||||
size = kwargs.get("size")
|
||||
# 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.upload_fileobj(fobj, to_info, overwrite=True, size=size)
|
||||
|
||||
|
||||
class WebDAVSFileSystem(WebDAVFileSystem): # pylint:disable=abstract-method
|
||||
scheme = Schemes.WEBDAVS
|
||||
@@ -1,56 +0,0 @@
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
from .fsspec_wrapper import CallbackMixin, FSSpecWrapper
|
||||
|
||||
|
||||
class WebHDFSFileSystem(CallbackMixin, FSSpecWrapper):
|
||||
scheme = Schemes.WEBHDFS
|
||||
REQUIRES = {"fsspec": "fsspec"}
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str) -> str:
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
return infer_storage_options(path)["path"]
|
||||
|
||||
def unstrip_protocol(self, path: str) -> str:
|
||||
host = self.fs_args["host"]
|
||||
port = self.fs_args["port"]
|
||||
path = path.lstrip("/")
|
||||
return f"webhdfs://{host}:{port}/{path}"
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
from fsspec.implementations.webhdfs import WebHDFS
|
||||
|
||||
return (
|
||||
WebHDFS._get_kwargs_from_urls( # pylint:disable=protected-access
|
||||
urlpath
|
||||
)
|
||||
)
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
self._ssl_verify = config.pop("ssl_verify", True)
|
||||
principal = config.pop("kerberos_principal", None)
|
||||
if principal:
|
||||
config["kerb_kwargs"] = {"principal": principal}
|
||||
return config
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from fsspec.implementations.webhdfs import WebHDFS
|
||||
|
||||
fs = WebHDFS(**self.fs_args)
|
||||
fs.session.verify = self._ssl_verify
|
||||
return fs
|
||||
|
||||
def checksum(self, path):
|
||||
ukey = self.fs.ukey(path)
|
||||
return ukey["bytes"]
|
||||
@@ -1,43 +0,0 @@
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
HASH_DIR_SUFFIX = ".dir"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HashInfo:
|
||||
name: Optional[str]
|
||||
value: Optional[str]
|
||||
obj_name: Optional[str] = field(default=None, compare=False)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.value)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}: {self.value}"
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.name, self.value))
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
if not d:
|
||||
return cls(None, None)
|
||||
|
||||
((name, value),) = d.items()
|
||||
return cls(name, value)
|
||||
|
||||
def to_dict(self):
|
||||
ret = OrderedDict()
|
||||
if not self:
|
||||
return ret
|
||||
|
||||
ret[self.name] = self.value
|
||||
return ret
|
||||
|
||||
@property
|
||||
def isdir(self):
|
||||
if not self:
|
||||
return False
|
||||
return self.value.endswith(HASH_DIR_SUFFIX)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user